Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

hellojade-intake (Java)

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.HttpClient and 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, 429 honors Retry-After without 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

Install

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.

Build and install into your local repository

git clone --branch v0.1.0 https://github.com/hellojade-ai/leads-java.git
cd leads-java && mvn install

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

As a submodule

git submodule add https://github.com/hellojade-ai/leads-java.git third_party/leads-java
cd third_party/leads-java && git checkout v0.1.0

Without a build tool

The 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.*;

Quickstart

1. Prove the key first — it stores nothing

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 host

2. Submit a lead

Map<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 null

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

3. Handle every outcome

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

Client options

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.

Client surface

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

Errors

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.

Retry and idempotency semantics

  1. Always pass an idempotencyKey, and make it your own stable id for the lead, namespaced to you: acme-leads:1234, not 1234, not a timestamp, not a fresh UUID per attempt. Dedupe is scoped to the tenant, so a bare 1234 can collide with another source's lead and yours is silently never stored. The client refuses an empty key, and one over 200 characters, with an IllegalArgumentException before any request goes out.
  2. A repeat of an accepted key returns 200 with the original event_id and status duplicate. That is success — it is what a retry is supposed to produce.
  3. Retries are automatic for transport errors (DNS, connect, TLS, timeout) and 5xx, with exponential backoff plus jitter, up to maxAttempts. The same Idempotency-Key and X-Request-Id go out on every attempt, so a request that actually arrived cannot create a second event.
  4. 429 waits max(Retry-After, backoff(n)) and does not consume a delivery attempt. Retry-After is a floor, not a strategy, so the wait grows with consecutive 429s. With no Retry-After header, or an unparseable one, the floor is one second.
  5. Any other 4xx is never retried. A 422 means the body needs fixing; a 401 means the configuration does.
  6. source is 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. Both Lead.Builder and submitLead(Map, ...) throw if you try to set it.
  7. Flags are not errors. phone_unnormalized, project_area_unknown, project_service_unknown, email_shape_suspect, extra_fields_preserved and country_unrecognized arrive on a successful response, in getFlags(). Read them, do not retry on them, do not treat them as a failure.
  8. A 422 does not consume the Idempotency-Key; send the same key again with a fixed body.
  9. requestId — pass your own correlation id (up to 64 characters) or let the client generate a 16-hex-character one. It is sent as X-Request-Id, echoed in the response header, and appears in any error body.
  10. Unmodeled fields are preserved. Anything in extra is sent at the top level and kept by the API, which sets the extra_fields_preserved flag. It is not an error.

Development

mvn test          # the suite
mvn verify        # the suite plus the jar, sources jar and javadoc jar

What was actually run before this was published

Read this before trusting any claim above.

  • The full suite was compiled and executed on the authoring machine, on OpenJDK 21.0.1259 tests, 59 passing, 0 failed, 0 skipped, in ~15 s.
  • It was not run through Maven, because mvn is not installed on that machine. It was compiled with javac --release 17 -Xlint:all (clean, zero warnings) and run with the junit-platform-console-standalone 1.10.3 launcher directly. pom.xml declares the same JUnit 5 dependency and the same release 17, but mvn test itself 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 CheckKey needs a real API key, and running SubmitLead against production would deliver a fake lead to a real customer.
  • No request has ever been made to intake.hellojade.ai from this repository. The suite talks only to a loopback com.sun.net.httpserver stub.

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.

What the suite covers

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.

Releasing

Tags on GitHub only — this artifact is not published to Maven Central, and CI does not deploy to any repository. See CONTRIBUTING.md.

License

MIT © hellojade

About

Java client for the hellojade Partner Intake API — java.net.http only, no runtime dependencies: idempotent submits, Retry-After-aware retries, typed errors

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages