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.

  1. Fetch what is already known. Call GET /api/existingLeads with mode=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 the ETag.
  2. 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.
  3. Push only genuinely new finds. Send them to POST /api/handleLeads in batches of up to 500, with an Idempotency-Key so a network retry cannot create duplicates.
  4. Read the outcome. The push returns a batch_id. Poll GET /api/leadBatches/{id} until status is done to get the authoritative per-lead result.
  5. Next run: call existingLeads again with updated_since set to your last sync time, or send the stored ETag as If-None-Match and get a 304 when 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

FieldTypeNotes
emailstringRequired. Normalized before storage.
namestringFull name. The first word is also stored as first_name.
companystring
categorystringFree text, e.g. Hair Salon. Campaigns target one category, so keep the wording consistent.
city, countrystringcountry as an ISO-2 code.
languagestringISO 639-1. Used to pick or translate the copy. Defaults to the brand language.
websitestringAlso used to derive company_domain for the one-contact-per-company rule.
instagram, phone, sourcestringFree text.
customobjectAnything else. Also: any unrecognised top-level key lands here automatically.
legal_basisstringlegitimate_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

statusreasonWhat happened
createdNew lead stored.
updatedenrichedAlready existed; only empty fields were filled in. Existing values are never overwritten.
duplicatealready_existsThis brand already has that address.
duplicateduplicate_in_batchThe same address appeared earlier in the same request.
rejectedinvalid_emailMissing or syntactically invalid address.
rejectedno_mxThe domain has no MX and no A record — mail could not be delivered.
rejectedsuppressedUnsubscribed, hard-bounced or complained. This one is never re-added.
rejectedstorage_errorSomething 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.

ParameterDefaultMeaning
brand_idRequired. Must match the token.
modefulllight returns only email_hash, status and updated_at. Use it when you only need to deduplicate.
limit500Page size, max 2000.
cursor0Pass back the next_cursor from the previous page. Cursor paging, not offsets — pages stay stable while leads are being added.
updated_sinceISO-8601. Only leads changed since then. This is how you do an incremental sync.
statusComma-separated list, e.g. new,contacted.
categoryExact match.
include_suppressedfalseAdds 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.

  1. Trim whitespace and lowercase the whole address.
  2. If it arrived as Name <a@b.sk>, keep only the part inside the angle brackets.
  3. Strip a trailing dot from the domain.
  4. For providers that genuinely ignore it (Gmail, Outlook, Hotmail, Live), drop everything after + in the local part; for Gmail also remove dots and fold googlemail.com into gmail.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": "..."}.

HTTPerrorWhat to do
401missing_tokenAdd the Authorization: Bearer header.
401invalid_tokenThe token is wrong or was revoked. Issue a new one.
403brand_mismatchbrand_id is not the token's brand. Fix the caller.
403insufficient_scopeThe token lacks leads:read or leads:write.
404not_foundNo such batch for this brand.
409A request with that Idempotency-Key is still running. Wait and poll results_url.
422idempotency_key_reusedSame key, different body. Use a fresh key.
422Validation failed; the body lists the offending fields.
429rate_limitedBack 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 handleLeads request.
  • 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.