Skip to content

Repository files navigation

hellojade-intake (Python)

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.

Install

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

Quickstart

1. Prove your key works — before you write any code, and again on go-live day

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 / revoked

check_key() never raises on 401; it returns valid=False so you can alert on it.

2. Submit a lead

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.

3. Vocabulary and health

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_writable

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

Async

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.

Client options

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

Responses and errors

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.

Client-side guards

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.

Retry and idempotency semantics

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()*jitter seconds between attempts, up to max_attempts total requests.
  • 429 sleeps max(Retry-After, backoff(n)) and retries without consuming a delivery attempt, up to max_rate_limit_waits times. Retry-After is currently 1 on both limiters; it is a floor, not a strategy, which is why the backoff grows.
  • Any other 4xx is never retried. A 422 means the body needs fixing; a 401 means the configuration does.
  • The same Idempotency-Key and the same X-Request-Id are 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.

Definition of done

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.

Development

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 src

See CONTRIBUTING.md, SECURITY.md and CHANGELOG.md. MIT licensed, © 2026 hellojade.

About

Python client for the hellojade Partner Intake API — submit leads with idempotency, retries and typed errors

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages