Leads API
Two endpoints. One tells you which contacts this brand already knows about, the other takes new ones. Everything is scoped to a single brand — a token can never see or touch another brand's data.
Base URL https://outreach.palus.sk · machine-readable spec:
/docs/api.json
Recommended flow
Do it in this order. It costs one cheap request and saves you from wasting research time on contacts that are already handled — and from getting your leads rejected.
-
Fetch what is already known. Call
GET /api/existingLeadswithmode=light&include_suppressed=1. You get back SHA-256 hashes of the normalized addresses plus their status — enough to deduplicate, small enough to pull often. Store theETag. - Research. Find candidates however you like. Before spending effort on one, hash the address the same way the API does (see how duplicates are decided) and skip anything already present.
-
Push only genuinely new finds. Send them to
POST /api/handleLeadsin batches of up to 500, with anIdempotency-Keyso a network retry cannot create duplicates. -
Read the outcome. The push returns a
batch_id. PollGET /api/leadBatches/{id}untilstatusisdoneto get the authoritative per-lead result. -
Next run: call
existingLeadsagain withupdated_sinceset to your last sync time, or send the storedETagasIf-None-Matchand get a304when nothing changed.
Authentication
Every request needs a bearer token. Tokens are created per brand in Setup → API tokens, are shown exactly once, and can be revoked at any time.
Authorization: Bearer olk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
You must also send brand_id in the body (POST) or the query string (GET).
It has to match the token's brand. A mismatch returns 403 brand_mismatch rather
than silently writing to the wrong place.
Tokens carry abilities: leads:write for handleLeads,
leads:read for existingLeads and leadBatches.
A token without the right ability gets 403 insufficient_scope.
POST /api/handleLeads
Takes an array of leads. Only email is required; everything else is optional.
Request
POST /api/handleLeads
Authorization: Bearer olk_...
Idempotency-Key: run-2026-08-01-batch-3
Content-Type: application/json
{
"brand_id": 1,
"leads": [
{
"email": "test@test.sk",
"name": "John Doe",
"category": "Hair Salon",
"company": "Salon Iris",
"city": "Bratislava",
"country": "SK",
"language": "sk",
"website": "https://saloniris.sk",
"instagram": "@saloniris",
"source": "google-maps",
"custom": { "chairs": 4, "rating": "4.8", "anything": "else" }
},
{ "email": "hello@another.sk" }
]
}
Fields
| Field | Type | Notes |
|---|---|---|
email | string | Required. Normalized before storage. |
name | string | Full name. The first word is also stored as first_name. |
company | string | |
category | string | Free text, e.g. Hair Salon. Campaigns target one category, so keep the wording consistent. |
city, country | string | country as an ISO-2 code. |
language | string | ISO 639-1. Used to pick or translate the copy. Defaults to the brand language. |
website | string | Also used to derive company_domain for the one-contact-per-company rule. |
instagram, phone, source | string | Free text. |
custom | object | Anything else. Also: any unrecognised top-level key lands here automatically. |
legal_basis | string | legitimate_interest (default), consent or contract. |
Every key in custom becomes a template variable, so
"chairs": 4 can be used in an email as {{chairs}}.
Response — 202 Accepted
Leads are processed on a queue (MX checks are slow and we would rather not hold your
connection open). You get a batch_id immediately, plus a best-effort
preview computed during the request. The authoritative per-lead result
lives at results_url.
{
"batch_id": 42,
"status": "queued",
"received": 2,
"results_url": "https://outreach.palus.sk/api/leadBatches/42",
"preview": [
{ "index": 0, "email": "test@test.sk", "status": "accepted", "reason": null },
{ "index": 1, "email": "hello@another.sk", "status": "duplicate", "reason": "already_exists",
"lead_status": "contacted" }
]
}
Idempotency
Send an Idempotency-Key header. Repeating the same key with the same body
returns the original response with Idempotent-Replay: true and creates nothing.
The same key with a different body is an error (422 idempotency_key_reused)
— that almost always means a bug on the caller's side.
GET /api/leadBatches/{batch_id}
The final result of a push. Poll it until status is done.
{
"batch_id": 42,
"status": "done",
"summary": { "total": 2, "created": 1, "updated": 0, "duplicate": 1, "rejected": 0 },
"results": [
{ "index": 0, "email": "test@test.sk", "normalized_email": "test@test.sk",
"status": "created", "reason": null, "message": null,
"lead_id": 918, "lead_status": "new", "warnings": [] },
{ "index": 1, "email": "hello@another.sk", "normalized_email": "hello@another.sk",
"status": "duplicate", "reason": "already_exists",
"message": "Lead already exists for this brand.", "lead_id": 511, "lead_status": "contacted" }
],
"error": null,
"finished_at": "2026-08-01T09:14:22+00:00"
}
Per-lead status values
| status | reason | What happened |
|---|---|---|
created | — | New lead stored. |
updated | enriched | Already existed; only empty fields were filled in. Existing values are never overwritten. |
duplicate | already_exists | This brand already has that address. |
duplicate | duplicate_in_batch | The same address appeared earlier in the same request. |
rejected | invalid_email | Missing or syntactically invalid address. |
rejected | no_mx | The domain has no MX and no A record — mail could not be delivered. |
rejected | suppressed | Unsubscribed, hard-bounced or complained. This one is never re-added. |
rejected | storage_error | Something failed on our side; safe to retry with a new Idempotency-Key. |
warnings may contain role_address — the address is a generic
info@-style inbox rather than a person. It is still stored; deciding whether to
mail it is up to you.
GET /api/existingLeads
Everything the brand already knows, so you can skip it.
| Parameter | Default | Meaning |
|---|---|---|
brand_id | — | Required. Must match the token. |
mode | full | light returns only email_hash, status and updated_at. Use it when you only need to deduplicate. |
limit | 500 | Page size, max 2000. |
cursor | 0 | Pass back the next_cursor from the previous page. Cursor paging, not offsets — pages stay stable while leads are being added. |
updated_since | — | ISO-8601. Only leads changed since then. This is how you do an incremental sync. |
status | — | Comma-separated list, e.g. new,contacted. |
category | — | Exact match. |
include_suppressed | false | Adds the brand's suppression list to the first page. Worth pulling — those addresses must never be sent again. |
GET /api/existingLeads?brand_id=1&mode=light&include_suppressed=1&limit=1000
Authorization: Bearer olk_...
If-None-Match: "9f2c8a1e5b7d..."
200 OK
ETag: "9f2c8a1e5b7d..."
{
"brand_id": 1,
"mode": "light",
"count": 1000,
"has_more": true,
"next_cursor": 1487,
"leads": [
{ "email_hash": "3b8f...c1", "status": "contacted", "updated_at": "2026-07-30T11:02:00+00:00" }
],
"suppressed": [
{ "email": null, "email_hash": "77aa...9e", "reason": "unsubscribe" }
]
}
In full mode each lead also carries name, company, company_domain, category,
city, country, language, website, instagram, source, status_reason, last_contacted_at,
last_replied_at and messages_sent.
Responses are cached briefly and carry an ETag. Send it back as
If-None-Match and an unchanged page costs you a 304 and no body.
How duplicates are decided
Addresses are normalized before anything is compared or stored, and a database-level unique index on (brand, normalized email) makes a duplicate physically impossible — even if two of your workers push the same contact at the same millisecond.
- Trim whitespace and lowercase the whole address.
- If it arrived as
Name <a@b.sk>, keep only the part inside the angle brackets. - Strip a trailing dot from the domain.
- For providers that genuinely ignore it (Gmail, Outlook, Hotmail, Live), drop everything after
+in the local part; for Gmail also remove dots and foldgooglemail.comintogmail.com. Other domains keep their+tag, because elsewhere it is a real, distinct mailbox.
The hash you compare against in light mode is:
email_hash = sha256(normalized_email) # lowercase hex
# python
import hashlib
hashlib.sha256("test@test.sk".encode()).hexdigest()
Errors
Errors are JSON: {"error": "code", "message": "...", "docs": "..."}.
| HTTP | error | What to do |
|---|---|---|
| 401 | missing_token | Add the Authorization: Bearer header. |
| 401 | invalid_token | The token is wrong or was revoked. Issue a new one. |
| 403 | brand_mismatch | brand_id is not the token's brand. Fix the caller. |
| 403 | insufficient_scope | The token lacks leads:read or leads:write. |
| 404 | not_found | No such batch for this brand. |
| 409 | — | A request with that Idempotency-Key is still running. Wait and poll results_url. |
| 422 | idempotency_key_reused | Same key, different body. Use a fresh key. |
| 422 | — | Validation failed; the body lists the offending fields. |
| 429 | rate_limited | Back off for the seconds given in Retry-After. |
5xx responses are safe to retry — reuse the same Idempotency-Key
and nothing will be created twice.
Limits
- Up to 500 leads per
handleLeadsrequest. - 120 requests per minute per token, across all endpoints.
existingLeads: 500 leads per page by default, 2000 maximum.- Request bodies over a few megabytes will be rejected by the web server — split the batch.
Outbound webhooks
Instead of polling for outcomes, you can be told. Configure endpoints in
Setup → Webhooks and subscribe to
lead.replied, reply.qualified, lead.bounced,
lead.unsubscribed, lead.complained, message.sent,
message.opened, message.clicked or campaign.paused.
POST https://your-endpoint.example.com/hook
X-Outreach-Event: reply.qualified
X-Outreach-Signature: sha256=<hmac of the raw body, keyed with the webhook secret>
X-Outreach-Delivery: 8123
{
"event": "reply.qualified",
"brand_id": 1,
"occurred_at": "2026-08-01T09:20:11+00:00",
"data": {
"inbound_id": 77, "lead_id": 918, "lead_email": "test@test.sk",
"campaign_id": 3, "classification": "interested", "confidence": 0.94,
"summary": "Asks for pricing and a call next week."
}
}
Verify the signature before trusting the body: it is
hmac_sha256(raw_body, webhook_secret), hex-encoded, prefixed with
sha256=. We retry once on failure and then give up — the delivery log in the UI
keeps the response so you can see why.
Also worth reading: SPF, DKIM, DMARC and the tracking domain — none of the above matters if the mail lands in spam.