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 like DE, and languages two-letter codes like de.
  • 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"

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:

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

ScopeWhat it allows
readLook at anything: campaigns, companies, results, contacts, replies, lists, events and settings.
writeMake and change things: campaigns, lists, messages, sender details, do-not-contact, members and settings.
sign_offApprove a campaign's list. Nothing can be sent until its list is approved.
sendSend a campaign whose list is approved, and nothing else.
webhooksManage 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"

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 Accepted and 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  }'

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"
Answer
{  "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.

Answer
{  "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"  }}
StatusWhat it means
400The request could not be read. The answer names the parameter at fault and what it must be.
401No key was sent, or the key is not valid, or it has expired.
403The key's scopes do not allow this call.
404There is no such thing, or it belongs to another organization.
409A request with this Idempotency-Key is still running, or the key was already used for a different request.
422The request was understood and refused. The message says why, in words you can act on.
429Too 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: ...

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"  }'

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.

EventWhen it is sent
campaign.discoveredA campaign found its companies, and its list waits for review.
campaign.attestedA campaign's list was approved, so it can send.
campaign.runningA campaign started sending, or carried on after a pause.
campaign.pausedA campaign was paused.
campaign.doneA campaign finished sending.
campaign.cancelledA campaign was stopped before it finished.
target.submittedA message reached a company through its contact form.
target.failedA message could not be sent to a company.
target.blockedA company's website stopped a message from getting through.
response.recordedA 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:

Event
{  "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.

Node.js
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 id to 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/v1/campaignsYour campaigns, one page at a timeread
  • POST/v1/campaignsStart a campaignwrite
  • GET/v1/campaigns/{id}One campaignread
  • PATCH/v1/campaigns/{id}Change a campaignwrite
  • PUT/v1/campaigns/{id}/assist-budgetSet what a campaign may spend on assistancewrite
  • POST/v1/campaigns/{id}/copiesCopy a campaignwrite
  • POST/v1/campaigns/{id}/copies/{country}Copy a campaign to another countrywrite
  • GET/v1/campaigns/{id}/cost-previewWhat a campaign would costread
  • GET/v1/campaigns/{id}/jobsThe work queued on a campaign, newest firstread
  • POST/v1/campaigns/{id}/jobsQueue work on a campaign: finding companies or reading their siteswrite
  • GET/v1/campaigns/{id}/locationsThe places a campaign searchesread
  • PUT/v1/campaigns/{id}/locationsSet the places a campaign searcheswrite
  • DELETE/v1/campaigns/{id}/locations/{location_code}Drop one place from a campaignwrite
  • POST/v1/campaigns/{id}/retriesTry the companies a campaign missed againwrite
  • POST/v1/campaigns/{id}/sendSend a campaign whose list is approvedsend
  • GET/v1/campaigns/{id}/sequenceThe follow-up steps of a campaignread
  • PUT/v1/campaigns/{id}/sequenceSet the follow-up steps of a campaignwrite
  • GET/v1/campaigns/{id}/signoffWhat approving a campaign's list would coverread
  • POST/v1/campaigns/{id}/signoffApprove a campaign's list, so it can be sentsign_off
  • POST/v1/campaigns/{id}/stopStop a campaignwrite
  • GET/v1/cost-estimateWhat a campaign with these numbers would set aside, before it existsread
  • GET/v1/countriesThe countries a campaign can be run inread
  • GET/v1/jobs/{id}One piece of queued workread
  • GET/v1/send-windows/{country}When a country's companies are written toread
  • GET/v1/territoriesThe saved territoriesread
  • POST/v1/territoriesSave a territorywrite
  • GET/v1/territories/{id}One saved territoryread
  • DELETE/v1/territories/{id}Delete a territorywrite
  • GET/v1/territories/estimateHow many companies a territory holdsread
  • DELETE/v1/url-testsClear the address checkswrite

Companies

The companies a campaign found, what happened with each, and who looks after them.

  • GET/v1/boardEvery company across your campaigns, with one status eachread
  • GET/v1/campaigns/{id}/targetsThe companies a campaign foundread
  • POST/v1/campaigns/{id}/targetsAdd one company to a campaign by addresswrite
  • GET/v1/campaigns/{id}/targets/{target_id}One company a campaign foundread
  • DELETE/v1/campaigns/{id}/targets/{target_id}Take one company off a campaignwrite
  • GET/v1/campaigns/{id}/targets/{target_id}/notesThe notes on one company of a campaignread
  • POST/v1/campaigns/{id}/targets/{target_id}/notesAdd a note to a companywrite
  • PUT/v1/campaigns/{id}/targets/{target_id}/ownerSet or clear who looks after a companywrite
  • POST/v1/campaigns/{id}/targets/{target_id}/restorePut back a company a reviewer took off a campaignwrite
  • GET/v1/captcha-freeThe companies whose contact form has no captcharead
  • GET/v1/captcha-free/summaryHow big the captcha-free list is and how freshread
  • GET/v1/companies/{domain}/statusWhat happened with one companyread
  • DELETE/v1/notes/{id}Withdraw a notewrite
  • GET/v1/site-factsWhat the companies' sites are built with and what failed on their sideread

Results and proof

What came back from a campaign, the contacts it collected and the proof kept for each delivery.

  • GET/v1/campaigns/{id}/block-reasonsWhy companies on a campaign were not contactedread
  • GET/v1/campaigns/{id}/contactsThe emails and phone numbers a campaign collectedread
  • GET/v1/campaigns/{id}/resultsWhat came back from a campaignread
  • GET/v1/campaigns/{id}/why-waitingWhy a campaign is not movingread
  • GET/v1/targets/{id}/evidenceThe proof kept for one deliveryread

Replies

Replies matched to your campaigns, answers you record, who a reply goes to, and follow-ups done by hand.

  • POST/v1/campaigns/{id}/targets/{target_id}/responsesRecord what a company answeredwrite
  • GET/v1/inbound-matchesMail matched to your campaigns: replies, automatic replies, bounces and opt-outsread
  • POST/v1/inbound-matchesHand in one mail from your forwarding addresswrite
  • GET/v1/inbound-matches/summaryReplies at a glance: your mailbox connection and counts for 7 and 30 daysread
  • DELETE/v1/responses/{id}Take back a recorded answerwrite
  • GET/v1/routing-rulesWho a reply is handed to, and the latest decisionsread
  • PUT/v1/routing-rulesReplace your reply routing ruleswrite
  • GET/v1/todosThe manual follow-ups that fell dueread
  • POST/v1/todos/{id}/doneMark a manual follow-up donewrite

Lists

Saved lists of companies to reuse across campaigns.

  • POST/v1/campaigns/{id}/importsAdd a saved list to a campaignwrite
  • POST/v1/campaigns/{id}/saved-listsSave a campaign's companies as a listwrite
  • GET/v1/listsThe saved listsread
  • POST/v1/listsMake a saved listwrite
  • GET/v1/lists/{id}One saved listread
  • PUT/v1/lists/{id}Rename a saved listwrite
  • DELETE/v1/lists/{id}Delete a saved listwrite
  • POST/v1/lists/{id}/campaignsStart a campaign from a saved listwrite
  • GET/v1/lists/{id}/domainsThe companies on a saved listread
  • POST/v1/lists/{id}/domainsAdd companies to a saved listwrite
  • DELETE/v1/lists/{id}/domains/{domain}Take one company off a saved listwrite
  • POST/v1/lists/{id}/domains/removeTake several companies off a saved listwrite
  • POST/v1/lists/{id}/importsAdd a campaign's companies to a saved listwrite
  • GET/v1/lists/{id}/overlapWhat two lists shareread

Messages

The messages you save, and the one a campaign sends.

  • PUT/v1/campaigns/{id}/messageChoose the message a campaign sendswrite
  • GET/v1/templatesThe saved messagesread
  • POST/v1/templatesWrite a messagewrite
  • GET/v1/templates/{id}One saved messageread
  • PUT/v1/templates/{id}Change a messagewrite
  • DELETE/v1/templates/{id}Delete a messagewrite

Sender details

The name, company and contact details a message is sent with.

  • GET/v1/sendersThe saved sender detailsread
  • POST/v1/sendersSave sender detailswrite
  • GET/v1/senders/{id}One set of sender detailsread
  • PUT/v1/senders/{id}Change sender detailswrite
  • DELETE/v1/senders/{id}Delete sender detailswrite

Do-not-contact

Companies your organization will never message, whatever campaign holds them.

  • GET/v1/do-not-contactYour do-not-contact listread
  • POST/v1/do-not-contactPut companies on your do-not-contact listwrite
  • DELETE/v1/do-not-contact/{domain}Take one company off your do-not-contact listwrite

Team and members

The people in your organization, their roles, invitations and what each has on their plate.

  • GET/v1/invitationsInvitations not yet acceptedread
  • POST/v1/invitationsInvite someone by emailwrite
  • DELETE/v1/invitations/{id}Cancel an invitationwrite
  • POST/v1/invitations/{id}/resendSend an invitation againwrite
  • GET/v1/membersThe people in your organizationread
  • PATCH/v1/members/{id}Change someone's rolewrite
  • DELETE/v1/members/{id}Remove someone from your organizationwrite
  • GET/v1/teamWhat each team member has on their plateread

Billing

Your balance, top-ups and every movement of money.

  • GET/v1/balanceThe balance, what is set aside and the top-up settingsread
  • PATCH/v1/balanceChange automatic top-up or the low balance emailwrite
  • POST/v1/top-upsAdd money to the balancewrite
  • GET/v1/top-ups/{id}One top-upread
  • GET/v1/transactionsMoney in and out, newest firstread

API keys

Who a key is, and the keys your organization holds.

  • GET/v1/api-keysYour organization's API keysread
  • POST/v1/api-keysMake an API keywrite
  • POST/v1/api-keys/{id}/revokeRevoke a keywrite
  • POST/v1/api-keys/{id}/rotateReplace a key's secretwrite
  • GET/v1/meWho this key isread

Webhooks and events

Where your software is told what happened, what was sent to it, and the log of events.

  • GET/v1/event-typesEvery kind of event an endpoint can ask forread
  • GET/v1/eventsWhat has happened in your organization, newest firstread
  • GET/v1/events/{id}One thing that happenedread
  • GET/v1/webhook-deliveries/{id}One event on its way to one endpointwebhooks
  • POST/v1/webhook-deliveries/{id}/resendSend one event to its endpoint againwebhooks
  • GET/v1/webhook-endpointsYour webhook endpointswebhooks
  • POST/v1/webhook-endpointsAdd a webhook endpointwebhooks
  • GET/v1/webhook-endpoints/{id}One webhook endpointwebhooks
  • PATCH/v1/webhook-endpoints/{id}Change an endpoint, or turn it on or offwebhooks
  • DELETE/v1/webhook-endpoints/{id}Delete a webhook endpointwebhooks
  • GET/v1/webhook-endpoints/{id}/deliveriesWhat has been sent to one endpoint, newest firstwebhooks
  • POST/v1/webhook-endpoints/{id}/resend-failedSend again everything that failed to an endpoint since a timewebhooks
  • POST/v1/webhook-endpoints/{id}/rotate-secretGive an endpoint a new secret, with the old one kept working for a whilewebhooks
  • POST/v1/webhook-endpoints/{id}/testSend one test event to an endpointwebhooks

Exports

Contacts or companies as a CSV or Excel file.

  • POST/v1/exportsExport contacts or companies as a filewrite
  • GET/v1/exports/{id}One export, and its file when readyread

Settings

Your organization's time zone and the daily digest.

  • POST/v1/digest/testSend yourself today's digest as a testwrite
  • GET/v1/settingsYour organization's time zone and daily digest settingsread
  • PATCH/v1/settingsChange your organization's time zone or daily digest settingswrite

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.