Python client for the hellojade Partner Intake API — the one endpoint a lead source
POSTs to when it has a lead for a hellojade customer. Zero runtime dependencies (stdlib
urllib), Python 3.9+, fully typed, with an optional async client on httpx.
- API documentation and live playground: https://intake.hellojade.ai/api
- OpenAPI contract: https://intake.hellojade.ai/api/openapi.json
- Integration brief (the eight rules): https://intake.hellojade.ai/api/INTEGRATION.md
- Partner program: https://hellojade.ai/developers/provide-leads
- Sibling kits:
leads-node(Node / TypeScript) andleads-js(browser).
This package is not published on PyPI; install it from GitHub, pinned to a tag:
pip install "git+https://github.com/hellojade-ai/leads-python.git@v0.1.0"
# with the async client (adds httpx):
pip install "hellojade-intake[async] @ git+https://github.com/hellojade-ai/leads-python.git@v0.1.0"Or in requirements.txt:
hellojade-intake @ git+https://github.com/hellojade-ai/leads-python.git@v0.1.0
(If hellojade ever publishes to PyPI, it would be python -m build then twine upload dist/*
from a tagged commit; the package name hellojade-intake is already reserved in pyproject.toml.)
The endpoint authenticates before it validates, so posting an empty body with your real
key returns 422 (valid key, empty body) or 401 (bad key) — and stores nothing: no
lead, no email, no CRM write, and no Idempotency-Key consumed.
import os
from hellojade_intake import IntakeClient
client = IntakeClient(os.environ["HELLOJADE_API_KEY"])
check = client.check_key()
print(check.valid, check.status, check.required)
# True 422 ['first_name', 'last_name', 'phone'] <- key is good, proceed
# False 401 [] <- key missing / mistyped / revokedcheck_key() never raises on 401; it returns valid=False so you can alert on it.
import os
from hellojade_intake import IntakeClient, Lead, ValidationError, ApiError, TransportError
client = IntakeClient(
os.environ["HELLOJADE_API_KEY"],
idempotency_namespace="acme-leads", # rule 3 - see "Idempotency" below
)
lead_id = "A-99812" # the id YOUR system already uses for this lead
lead: Lead = {
"first_name": "Dana",
"last_name": "Whitfield",
"phone": "(630) 555-0142",
"email": "dana.whitfield@example.com",
"street_address": "418 N Maple St",
"city": "Naperville",
"state": "IL",
"zip": "60540",
"country": "US",
"project_area": "roof",
"project_service": "replacement", # replacement | repair | remodel | maintain
"project_material": "asphalt shingle",
"project_details": "Hail damage on the south slope, insurance claim already filed.",
"external_id": lead_id,
# "cost": 55.00, # only if you charge for the lead; never 0
}
try:
accepted = client.submit_lead(lead, idempotency_key=lead_id)
except ValidationError as e:
print("fix these and resend with the same idempotency key:", e.fields)
except ApiError as e:
print("rejected", e.status, e.code, "request_id=", e.request_id)
except TransportError as e:
print("unreachable after", e.attempts, "attempts:", e)
else:
print(accepted.status, accepted.event_id, accepted.source, accepted.flags)
# accepted evt_0198f2c1a4b00000a3d19f4c2b7e acme-leads []Only first_name, last_name and phone are required. Send everything else you have and
nothing you do not; never invent placeholder values. Unmodeled top-level keys are kept
by the server under extra (you get an extra_fields_preserved flag back), so send your
own fields at the top level rather than dropping them.
Full runnable versions: examples/check_key.py,
examples/submit_lead.py,
examples/async_submit.py.
vocab = client.vocabulary() # GET /v1/vocabulary - unauthenticated, cache for 5 min
[t.area for t in vocab.project_area] # ['attic', 'basement', ..., 'roof', ...]
vocab.project_service # ['replacement', 'repair', 'remodel', 'maintain']
vocab.required # ['first_name', 'last_name', 'phone']
health = client.health() # GET /healthz - returns the body on 200 AND 503
health.ok, health.store_writableDo not hard-code the project_area list: it grows by database insert on hellojade's
side, without a deploy. An unrecognized value is not an error — it is stored as sent and
flagged project_area_unknown — so send what your system actually calls it.
from hellojade_intake.aio import AsyncIntakeClient # needs the [async] extra (httpx)
async with AsyncIntakeClient(os.environ["HELLOJADE_API_KEY"], idempotency_namespace="acme-leads") as c:
accepted = await c.submit_lead(lead, idempotency_key=lead_id)Same surface, same errors, same retry policy.
| option | default | notes |
|---|---|---|
api_key |
None |
required for submit_lead() and check_key(). From your secret store, never source |
base_url |
https://intake.hellojade.ai |
must be https:// (http:// only for 127.0.0.1 / localhost stubs). Make it configuration |
timeout |
20.0 s |
the server bounds its own handler at 20 s |
user_agent |
hellojade-intake-python/0.1.0 |
|
retry |
RetryPolicy() |
see below |
idempotency_namespace |
None |
when set, every Idempotency-Key is sent as <ns>:<key> |
sleep, random |
time.sleep, random.random |
injectable for tests |
opener / transport |
urllib |
route requests through your own opener, or replace the transport entirely |
| HTTP | error |
what it means | this client |
|---|---|---|---|
202 |
— | accepted and committed to disk | returns Accepted(status="accepted") |
200 |
— | duplicate Idempotency-Key; same event_id as the first time |
returns Accepted(status="duplicate") — success, not an error |
400 |
invalid_json |
body is not a JSON object | raises ApiError, no retry |
401 |
unauthorized |
key missing, mistyped, or revoked | raises ApiError (check_key() returns valid=False instead), no retry |
405 |
method_not_allowed |
wrong HTTP method | raises ApiError, no retry |
413 |
body_too_large |
body over 64 KiB | raises ApiError, no retry |
422 |
validation_failed |
every failing field, at once | raises ValidationError with .fields ({"phone": "required", ...}), no retry |
429 |
rate_limited |
per-key or per-IP limit | waits Retry-After, retries without consuming an attempt; raises RateLimited(retry_after=...) after max_rate_limit_waits |
503 |
not_accepting |
hellojade's store is unwritable | retries with backoff; raises ApiError after max_attempts |
| network / timeout | — | never got a response | retries with backoff; raises TransportError after max_attempts |
Every ApiError carries status, code, message, request_id and body. Log
request_id on every failure — when there is no event_id, it is the only handle
hellojade support can find your request by. Pass your own correlation id as
submit_lead(..., request_id="acme-leads/req/8f21c3") and the server adopts it.
Flags are not errors. phone_unnormalized, project_area_unknown,
project_service_unknown, email_shape_suspect, extra_fields_preserved and
country_unrecognized come back in Accepted.flags on a successful submit so you can
self-correct without a support thread. Do not retry on a flag.
The client raises ValueError before sending in exactly three cases: the lead contains a
source key (your API key's registered label is the source — ask for a second key if you
need a second source), extra is present and not an object, or base_url is http://
against a non-local host. Everything else is left to the server, which reports every failing
field in a single 422.
Idempotency (rules 2 and 3). idempotency_key is a required keyword argument. Make it
your own stable id for the lead — not a timestamp, not a UUID minted at send time — so a
retry carries the same key and hellojade returns 200 with the original event_id instead
of a second lead. Dedupe is scoped to the customer, not to your key: a hellojade customer
may have dozens of sources posting, and a bare "1234" will silently collide with another
source's "1234" (you get their 200, your lead is never stored). Set
idempotency_namespace="acme-leads" and the client sends acme-leads:1234.
Retries (rule 5). RetryPolicy(max_attempts=5, max_rate_limit_waits=10, base_delay=1.0, max_delay=30.0, jitter=0.5):
- 5xx and transport errors retry with
min(max_delay, base_delay * 2**(n-1)) + random()*jitterseconds between attempts, up tomax_attemptstotal requests. - 429 sleeps
max(Retry-After, backoff(n))and retries without consuming a delivery attempt, up tomax_rate_limit_waitstimes.Retry-Afteris currently1on both limiters; it is a floor, not a strategy, which is why the backoff grows. - Any other 4xx is never retried. A
422means the body needs fixing; a401means the configuration does. - The same
Idempotency-Keyand the sameX-Request-Idare sent on every attempt, so a retry can never create a duplicate even if the first attempt actually arrived. health()never retries;vocabulary()retries 5xx only.
Pass retry=NO_RETRY to make a single attempt.
Before calling your integration finished, check it against the list in
INTEGRATION.md §8: check_key() returns
valid=True with the key you ship; the same lead twice returns 202 then 200 with the same
event_id; a 422 surfaces all three required fields; a 422 does not retry; a 429 waits
without consuming an attempt; and the key appears in no log, error message or committed file.
Do not test with real-looking leads against production. A real name and phone reach a
real salesperson. Use check_key(), run the suite against the local stub, or ask hellojade
for a sandbox key.
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/python -m unittest discover -s tests -t . -v # 44 tests against a local stub
.venv/bin/mypy srcSee CONTRIBUTING.md, SECURITY.md and CHANGELOG.md. MIT licensed, © 2026 hellojade.