Java client for the hellojade Partner Intake API — one POST that hands a lead to a
hellojade customer, durably, with idempotency and sane retries built in.
- Java 17+,
java.net.http.HttpClientand a bundled JSON codec only — no runtime dependencies (<dependencies>holds JUnit and nothing else, at test scope) - Typed
Lead,Accepted,ApiException,ValidationException.getFields(),RateLimitedException.getRetryAfter() - A retry policy that follows the API's rules: 5xx and timeouts back off,
429honorsRetry-Afterwithout spending a delivery attempt, every other 4xx is final - The API key never appears in an exception, a
toString(), a stack trace or a log line (tested)
| API reference and live playground | https://intake.hellojade.ai/api |
| OpenAPI 3.0 contract | https://intake.hellojade.ai/api/openapi.json |
| Integration brief (the eight rules) | https://intake.hellojade.ai/api/INTEGRATION.md |
| Becoming a lead provider | https://hellojade.ai/developers/provide-leads |
| Other kits | Go · CLI · Rust · Node · Browser JS · Python · Ruby · PHP · .NET |
This artifact is not published to Maven Central, and there is no GitHub Packages feed. It is consumed from GitHub — clone or submodule the repository, build the jar, and install it into your own repository or drop it on the classpath. CI builds the jar, the sources jar and the javadoc jar on every push as a check that packaging works, and uploads them as workflow artifacts; nothing is ever deployed to a feed.
git clone --branch v0.1.0 https://github.com/hellojade-ai/leads-java.git
cd leads-java && mvn installThen depend on it as usual:
<dependency>
<groupId>ai.hellojade</groupId>
<artifactId>intake</artifactId>
<version>0.1.0</version>
</dependency>Gradle: implementation("ai.hellojade:intake:0.1.0"), with mavenLocal() (or your internal
repository) in repositories.
git submodule add https://github.com/hellojade-ai/leads-java.git third_party/leads-java
cd third_party/leads-java && git checkout v0.1.0The library is one package with no dependencies, so javac alone is enough:
javac -d classes $(find src/main/java -name '*.java')
jar cf intake-0.1.0.jar -C classes .| Maven coordinates | ai.hellojade:intake:0.1.0 |
| Package | ai.hellojade.intake |
| Compiled for | release 17 (runs on 17 and newer; CI tests 17 and 21) |
| Runtime dependencies | none |
import ai.hellojade.intake.*;The API authenticates before it validates, so an empty body sent with a valid key comes
back 422 and nothing is stored, delivered or emailed. Do this before writing any other
code, and again on launch day.
IntakeClient client = IntakeClient.builder()
.apiKey(System.getenv("HELLOJADE_API_KEY"))
.build();
boolean ok = client.checkKey();
// true — 422: the key is valid and active, and nothing was stored
// false — 401: missing, mistyped, revoked, or pointed at the wrong hostMap<String, Object> extra = new LinkedHashMap<>();
extra.put("partner_job_id", "XZ-1"); // preserved by the API, never rejected
Lead lead = Lead.builder()
.firstName("Dana") // required
.lastName("Whitfield") // required
.phone("(630) 555-0142") // required
.email("dana.whitfield@example.com")
.streetAddress("418 N Maple St")
.city("Naperville")
.state("IL")
.zip("60540")
.country("US")
.projectArea("roof") // live list: client.vocabulary()
.projectService("replacement") // replacement | repair | remodel | maintain
.projectMaterial("asphalt shingle")
.projectDetails("Hail damage on the south slope, insurance claim already filed.")
.externalId("A-99812")
.cost(555.55) // 0.01-999.99; omit when there is no charge, never send 0
.extra(extra)
.build();
Accepted result = client.submitLead(lead, "acme-leads:A-99812", "acme-leads/A-99812");
result.getEventId(); // "evt_0198f2c1a4b00000a3d19f4c2b7e" - store this against your lead
result.getStatus(); // "accepted" (202) or "duplicate" (200) - both are success
result.isAccepted(); // true on a 202
result.isDuplicate(); // true on a 200
result.getSource(); // your key's registered label
result.getFlags(); // non-fatal observations, never errors, never nullOnly firstName, lastName and phone are required. Send everything you have and nothing
you do not — an unset optional field is omitted from the JSON, never sent as null or a
placeholder. Do not send source: it comes from your API key, and both Lead.Builder
and submitLead refuse it. submitLead also accepts a plain Map<String, ?> with
snake_case keys, and Lead.fromMap builds a Lead from one, routing unmodeled keys into
extra.
try {
Accepted result = client.submitLead(lead, "acme-leads:" + leadId, null);
} catch (ValidationException e) { // 422
e.getFields(); // { "first_name": "required", "phone": "required" } - ALL failing fields at once
e.getRequestId();
} catch (UnauthorizedException e) { // 401 - a configuration problem, not a retry
} catch (RateLimitedException e) { // 429, after the wait budget is spent
e.getRetryAfter();
} catch (ApiException e) { // 400, 413, or a 5xx after retries
e.getStatus(); e.getCode(); e.getRequestId(); e.getAttempts();
} catch (TransportException e) { // no response after retries
e.getAttempts();
}UnauthorizedException, ValidationException and RateLimitedException all extend
ApiException, which extends IntakeException alongside TransportException — so order
the catch blocks most specific first. Every one is unchecked. Runnable versions of all
three flows are in examples/.
IntakeClient client = IntakeClient.builder()
.apiKey(System.getenv("HELLOJADE_API_KEY")) // required; never a source constant
.baseUrl("https://intake.hellojade.ai") // the default. HTTPS only - nothing listens on port 80
.timeout(Duration.ofSeconds(20)) // connect and per request; the API bounds its handler at 20 s
.userAgent("acme-leads-sync/2.3") // APPENDED to hellojade-intake-java/<version>
.retryPolicy(RetryPolicy.builder()
.maxAttempts(5) // delivery attempts (a 429 does not consume one)
.maxRateLimitWaits(10) // consecutive 429s to wait out before throwing RateLimitedException
.baseDelay(1.0) // backoff = min(base * 2^(n-1), max) + rand * jitter, in seconds
.maxDelay(30.0)
.jitter(0.5)
.build())
.httpClient(null) // supply your own (proxy, executor); its connect timeout is then yours
.sleeper(null) // seconds -> void; the tests use this to assert backoff without waiting
.build();RetryPolicy.none() makes exactly one attempt, for callers that run their own loop.
IntakeClient is immutable and safe to share across threads; build one and keep it.
| method | HTTP | returns |
|---|---|---|
checkKey() / checkKey(requestId) |
POST /v1/intake with {} |
true on 422, false on 401 |
submitLead(lead, idempotencyKey, requestId) |
POST /v1/intake |
Accepted on 202 or 200 |
vocabulary() |
GET /v1/vocabulary (unauthenticated) |
Vocabulary — project_area terms with status, the project_service enum, required |
health() |
GET /healthz (unauthenticated) |
Health for both 200 and 503 |
lead may be a Lead or a Map<String, ?> with snake_case keys. requestId may be
null, in which case the client generates one.
| HTTP | API error |
thrown | retried? | what to do |
|---|---|---|---|---|
| 202 | — | (returns Accepted, status accepted) |
— | store getEventId(); done |
| 200 | — | (returns Accepted, status duplicate) |
— | same event_id as before; done |
| 400 | invalid_json |
ApiException |
no | log, alert |
| 401 | unauthorized |
UnauthorizedException |
no | fix the key or host; see the key check |
| 413 | body_too_large |
ApiException |
no | body over 64 KiB — trim projectDetails |
| 422 | validation_failed |
ValidationException (.getFields()) |
no | fix every listed field |
| 429 | rate_limited |
RateLimitedException (.getRetryAfter()) only after the wait budget |
yes — waits max(Retry-After, backoff) |
usually nothing; back off further if sustained |
| 503 | not_accepting |
ApiException after maxAttempts |
yes — exponential backoff | this is hellojade's side |
| other 5xx | — | ApiException after maxAttempts |
yes | |
| no response | — | TransportException after maxAttempts |
yes | check https://, egress, DNS |
Every ApiException carries getRequestId() (from the body, or the X-Request-Id header
when the body has none), getStatus(), getCode(), the raw getBody() and the
getAttempts() made. Quote the request_id — or the event_id — in any support
conversation. Never the key. When the body is not JSON at all (a proxy's HTML 502, say),
getCode() falls back to http_<status> and getBody() holds what arrived.
- Always pass an
idempotencyKey, and make it your own stable id for the lead, namespaced to you:acme-leads:1234, not1234, not a timestamp, not a fresh UUID per attempt. Dedupe is scoped to the tenant, so a bare1234can collide with another source's lead and yours is silently never stored. The client refuses an empty key, and one over 200 characters, with anIllegalArgumentExceptionbefore any request goes out. - A repeat of an accepted key returns
200with the originalevent_idand statusduplicate. That is success — it is what a retry is supposed to produce. - Retries are automatic for transport errors (DNS, connect, TLS, timeout) and 5xx, with
exponential backoff plus jitter, up to
maxAttempts. The sameIdempotency-KeyandX-Request-Idgo out on every attempt, so a request that actually arrived cannot create a second event. 429waitsmax(Retry-After, backoff(n))and does not consume a delivery attempt.Retry-Afteris a floor, not a strategy, so the wait grows with consecutive 429s. With noRetry-Afterheader, or an unparseable one, the floor is one second.- Any other 4xx is never retried. A
422means the body needs fixing; a401means the configuration does. sourceis not a request field. It is the registered label of the API key that authenticated — attribution is a fact about who you are, not about what you claimed. BothLead.BuilderandsubmitLead(Map, ...)throw if you try to set it.- Flags are not errors.
phone_unnormalized,project_area_unknown,project_service_unknown,email_shape_suspect,extra_fields_preservedandcountry_unrecognizedarrive on a successful response, ingetFlags(). Read them, do not retry on them, do not treat them as a failure. - A
422does not consume theIdempotency-Key; send the same key again with a fixed body. requestId— pass your own correlation id (up to 64 characters) or let the client generate a 16-hex-character one. It is sent asX-Request-Id, echoed in the response header, and appears in any error body.- Unmodeled fields are preserved. Anything in
extrais sent at the top level and kept by the API, which sets theextra_fields_preservedflag. It is not an error.
mvn test # the suite
mvn verify # the suite plus the jar, sources jar and javadoc jarRead this before trusting any claim above.
- The full suite was compiled and executed on the authoring machine, on OpenJDK 21.0.12 — 59 tests, 59 passing, 0 failed, 0 skipped, in ~15 s.
- It was not run through Maven, because
mvnis not installed on that machine. It was compiled withjavac --release 17 -Xlint:all(clean, zero warnings) and run with thejunit-platform-console-standalone1.10.3 launcher directly.pom.xmldeclares the same JUnit 5 dependency and the samerelease 17, butmvn testitself has never been run locally — GitHub Actions is the first place Maven resolves this POM. - Only Java 21 was exercised locally. Java 17 is covered by the CI matrix and nowhere else.
- The examples were compiled, not run — running
CheckKeyneeds a real API key, and runningSubmitLeadagainst production would deliver a fake lead to a real customer. - No request has ever been made to
intake.hellojade.aifrom this repository. The suite talks only to a loopbackcom.sun.net.httpserverstub.
Treat the CI run on the commit you are reading as the authority for Maven and for Java 17; read the workflow and its logs rather than this paragraph.
Every documented status code — 200, 202, 400, 401, 413, 422, 429, 500, 502 and 503 — plus
timeouts and refused connections, against a local stub. It asserts the exact backoff
sequence, that Retry-After is honored as a floor, that a 429 does not consume a delivery
attempt, that the same Idempotency-Key is resent on every retry, that source is refused
on both submit paths, and that the API key reaches neither an exception message, a
toString() nor a stack trace.
Tags on GitHub only — this artifact is not published to Maven Central, and CI does not deploy to any repository. See CONTRIBUTING.md.
MIT © hellojade