Skip to content

feat: validate options, AI scoring, filters, list evaluator, Inbox Placement Tester and the S3/SFTP bulk jobs - #81

Open
franciscbalint wants to merge 7 commits into
masterfrom
feature/api-parity-p1-validate
Open

franciscbalint wants to merge 7 commits into
masterfrom
feature/api-parity-p1-validate

Conversation

@franciscbalint

@franciscbalint franciscbalint commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Ports 407f0a8 and b0444f8 from the Python SDK. Everything below is documented at https://www.zerobounce.net/docs/. Nothing undocumented was added.

Validate options

validate gains an overload taking ZBValidateOptions: timeout, activity_data, verify_plus, domain_info, greylist_processing, callback_url, and usePost for the form-encoded POST /v2/validate. An unset option is not sent, and booleans go on the wire as the strings "true"/"false".

callback_url is where the greylist retry posts its result, so it is rejected without greylist_processing, and when blank — before any request is sent. timeout must be 3–60 seconds.

ZBValidateResponse gains activeInDays, activeFirstSeen, domainWebsiteExists, domainRegistrantCompanyName and message. message is the API's Message key, returned instead of a result while a greylist retry is still running; it is part of equals/hashCode/toString like the rest.

Real-time AI scoring, filters, list evaluator

  • scoreEmail (GET /v2/scoring) and its POST variant.
  • addFilter, listFilters, deleteFilter.
  • getListEvaluatorResult, on the list evaluator's own base URL rather than a path built from the bulk one, with the file id URL-encoded as a single path segment.

S3 and SFTP/FTP bulk

These are on the bulkapi host without /v2, so they use a separate bulkApiRootUrl. All six file jobs share ZBBulkFileJobOptions and one bulkRootFileJob helper.

Two shapes differ from the rest and are easy to get wrong:

  • validateSFTPFile's file_name is optional — leaving it unset uses the input bucket from the account's settings. Every other job requires it.
  • exportSFTPFile sends export_path, not the export_bucket that exportS3File sends.

The SFTP server, path and protocol are never sent by the SDK; they come from the account's ZeroBounce settings.

Inbox Placement Tester

The API key goes on the query string, bodies are camelCase JSON, and unset fields are left out. Spends the account's inbox-test credits.

Notes

  • sendRequest could only do a GET or a JSON POST. sendRequestWithMethod is new: an explicit method plus either form fields or a JSON body, which the bulk jobs, the filters and the Tester's PUT and DELETE all need.
  • initialize gains a four-argument overload for the list evaluator and Tester base URLs. They are separate hosts, not paths built from the bulk base, so overriding apiBaseUrl alone would leave a test pointing at the live API — which is how the equivalent Rust test first failed.
  • ErrorResponse.parseError already collects every value of an error body, so the Tester's Django-style {"detail": ...} surfaces without a special case. A test now pins that.
  • AGENTS.md is new, and records the four base URLs, the two bulk shapes above, and how to run the tests — there is no mvn on the host and mvnw has no wrapper jar committed, so Maven runs in Docker.

Test plan

  • mvn -B test in the Java 21 + Maven image — 58 pass, up from 46 (12 new in OfflineApiTest)
  • Offline only: a mocked HttpURLConnection answers every call and records the url, method and body, so the assertions are on what actually went on the wire

🤖 Generated with Claude Code


RD-66: ai_agent_mailbox sub_status

Folded in from the former feature/ai-agent-mailbox-substatus branch, which has been deleted now that the commit lives here.

Adds the ai_agent_mailbox sub_status, which the API returns alongside both valid and catch-all. Tests cover both pairings, plus an unrecognised sub_status to pin that a value added to the API after this SDK ships degrades gracefully instead of failing the whole validate call.

No sub_status_ai_agent_mailbox usage counter was added: it is not documented.


Python-SDK parity, verified against the live API

Brings this SDK level with the Python SDK's master, then checks every call against the real API. Where the live API and the docs disagree, the API wins and the call site says so.

What the live API taught us. Each of these was a real defect, found by calling the endpoint:

  • validatebatch takes timeout as a JSON string. Sent as a number, the API rejects the whole batch with the account-level "Invalid API Key or your account ran out of credits" — which looks like a billing problem, not a payload one. validateBatch had no options overload at all until now, so the batch could not send timeout, activity_data or verify_plus.
  • The Inbox Placement Tester authenticates with Authorization: Bearer, not an api_key parameter — the key no longer reaches a query string that gets logged — and its listing is GET /tests. GET /test answers Method "GET" not allowed, so listInboxPlacementTests had never worked.
  • send-evaluated-file takes the list evaluator's file_id as a form field, not the file upload the docs describe. The endpoint was missing from this SDK; sendEvaluatedFile(String fileId, …) adds it.
  • The S3 and SFTP jobs require file_name and return_url — validateSFTPFile included, although the docs list file_name as optional for that one.

Three bugs the live run exposed in this SDK.

  • listFilters threw. GET /filters/list answers with a JSON array, and the call deserialized into a JsonObject: Expected a JsonObject but was JsonArray. It now hands back a JsonElement.
  • The Warmup client threw on /seed-count, which answers with a bare number. Any payload that is not an object now comes back wrapped under data. While fixing it: Gson parses leniently, so a junk body was being read as a string rather than raising — the shape is now checked before parsing.
  • {"success": false} on an HTTP 200 was returned as data. The API answers an unknown bulk file id that way. It now raises, and ErrorResponse.getStatusCode() carries the HTTP status on every path, including the multipart uploads and the bulk download, so callers can tell a Cloudflare 403 from an API 400/404.

New: the Warmup API client. ZeroBounceWarmup covers all 26 documented endpoints of what is a separate product — its own host, its own team-scoped key in an API-Key header, two CSV downloads. Ranges are checked before sending (engagement rules 0–4, reply 5–45 %, important 5–40 %, click ≤ 70 % and ≤ important × 1.75), 204 and empty 200 come back as null, checkDomainDmarc requires an email address (the live API rejects a bare domain), and the SMTP test masks the credentials out of every error it raises. A key with surrounding whitespace or control characters is refused before it can be quoted unredacted by the transport.

Testing. 95 offline tests pass (mvn test), no network. The live suite (live/LiveApi.java) sits outside the Maven module's source root, so mvn test and CI never compile or run it; the last full run with ZEROBOUNCE_LIVE_SPEND=1 ZEROBOUNCE_LIVE_CHANGES=1 was 40 passed, 0 failed, 4 skipped (the four skips are Warmup statistics endpoints, which need a sending account this team does not have). It never creates an Inbox Placement test, sets the Inbox Placement webhook, or starts ZB-managed Warmup onboarding, because the API cannot undo any of those; the filter and sending account it does create are deleted in cleanup even when a test fails.

No version bump, tag or publish.

🤖 Generated with Claude Code

franciscbalint and others added 7 commits October 24, 2026 00:19
… bulk jobs

Ports 407f0a8 and b0444f8 from the Python SDK. Everything below is documented
at https://www.zerobounce.net/docs/; nothing undocumented was added.

- A validate overload taking ZBValidateOptions: timeout, activity_data,
  verify_plus, domain_info, greylist_processing, callback_url, and usePost for
  the form-encoded POST /v2/validate. An unset option is not sent; booleans go
  on the wire as the strings true/false. callback_url is where the greylist
  retry posts its result, so it is rejected without greylist_processing, and
  when blank, before any request is sent. timeout must be 3-60 seconds.
- ZBValidateResponse gains activeInDays, activeFirstSeen, domainWebsiteExists,
  domainRegistrantCompanyName and message. message is the API's "Message" key,
  returned instead of a result while a greylist retry is still running; it is
  part of equals/hashCode/toString like the rest.
- Real-time AI scoring: scoreEmail (GET /v2/scoring) and its POST variant.
- Account filters: addFilter, listFilters, deleteFilter.
- getListEvaluatorResult, on the list evaluator's own base URL rather than a
  path built from the bulk one, with the file id URL-encoded as one segment.
- The S3 bulk jobs (listS3Files, validateS3File, scoreS3File, exportS3File,
  emailFinderS3File, domainSearchS3File) and the SFTP/FTP ones
  (validateSFTPFile, scoreSFTPFile, emailFinderSFTPFile, domainSearchSFTPFile,
  exportSFTPFile). These are on the bulkapi host WITHOUT /v2, so they use a
  separate bulkApiRootUrl.
- The Inbox Placement Tester: the API key goes on the query string, bodies are
  camelCase JSON, and unset fields are left out.

Two bulk shapes differ from the rest and are easy to get wrong:
- validateSFTPFile's file_name is optional; leaving it unset uses the input
  bucket from the account's settings. Every other job requires it.
- exportSFTPFile sends export_path, not the export_bucket exportS3File sends.

sendRequest could only do a GET or a JSON POST, so sendRequestWithMethod is new:
it takes an explicit method and either form fields or a JSON body, which the
bulk jobs, the filters and the Tester's PUT and DELETE all need.

initialize gains a four-argument overload for the list evaluator and Tester
base URLs. They are separate hosts, not paths built from the bulk base, so
overriding apiBaseUrl alone would leave a test pointing at the live API.

ErrorResponse.parseError already collects every value of an error body, so the
Tester's Django-style {"detail": ...} surfaces without a special case; a test
now pins that.

AGENTS.md is new, and records the four base URLs, the two bulk shapes above,
and how to run the tests (there is no mvn on the host and mvnw has no wrapper
jar committed, so Maven runs in Docker).

Tests: 58 pass, up from 46. Offline only — a mocked HttpURLConnection answers
every call and records the url, method and body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The API now returns ai_agent_mailbox under both the valid and catch-all
statuses. No new status was added.

Adds AI_AGENT_MAILBOX to ZBValidateSubStatus with its @SerializedName,
following ROLE_BASED_CATCH_ALL.

Unknown values were already safe: Gson maps an unrecognised enum constant to
null rather than throwing, so a sub_status added after this SDK shipped
degrades to null instead of failing the validate call. That had no coverage,
so a test now pins it alongside the two new ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified against the live API; where the API and the docs disagree, the
API wins and the call site says so.

- validateBatch gains a ZBValidateOptions overload, and sends timeout as
  a JSON string. Sent as a number the API rejects the whole batch with
  the account-level "Invalid API Key or your account ran out of
  credits". Until now the batch had no options at all.
- The Inbox Placement Tester authenticates with an Authorization: Bearer
  header, not an api_key parameter, so the key no longer reaches a query
  string that gets logged. Its listing is GET /tests; /test is the
  single-test path and answers Method "GET" not allowed.
- sendEvaluatedFile posts the list evaluator's file_id as a form field.
  The endpoint was missing from this SDK.
- The S3 and SFTP jobs require file_name and return_url, validateSFTPFile
  included, although the docs list file_name as optional for that one.

{"success": false} on an HTTP 200 is a failure as well: the API answers
an unknown file id that way. ErrorResponse now carries the HTTP status
through getStatusCode(), on every path including the multipart uploads
and the bulk download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Warmup API is a separate product: its own host, its own team-scoped
key, and an API-Key header rather than an api_key parameter.
ZeroBounceSDK stays the validation client; ZeroBounceWarmup covers all
26 documented endpoints, including the two CSV downloads.

Two live findings are recorded at the call site: check-domain-dmarc
takes an email address, not the domain the docs describe, and a failed
SMTP login comes back as an HTTP 500. The key is masked out of every
message, and the SMTP username and password out of errors from that
method.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
live/LiveApi.java calls every SDK method against the real API. It lives
outside the Maven module's source root, so `mvn test` and CI never
compile or run it; it is compiled and run by hand with keys in the
environment.

Free calls run by default: sandbox addresses, reads, and requests
expected to fail that still prove the API accepts the payload. The free
tier asserts the credit balance did not move. ZEROBOUNCE_LIVE_SPEND=1
adds the billable calls and a bulk round trip that deletes its own job;
ZEROBOUNCE_LIVE_CHANGES=1 adds account changes, each undone in cleanup
even when the test fails. Nothing the API cannot undo is ever called.

Two things only the live run could catch:

- listFilters threw. GET /filters/list answers with a JSON array, not an
  object, so it now hands back a JsonElement.
- The Warmup client threw on /seed-count, which answers with a bare
  number. Any payload that is not an object now comes back wrapped under
  "data", and a body that is not JSON at all is rejected before Gson's
  lenient parser can read it as a string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README gains a Warmup API section and the opt-in live suite with its two
flags, and its S3/SFTP examples now pass the file_name and return_url
the API requires. AGENTS.md records where the live API and the docs
disagree, the rule that the API wins and the call site says so, and the
release rule that the live suite is run by hand before every release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by auditing every SDK's api-usage model against the keys the live API
actually returns, after the same class of bug turned up in Rust and iOS.

sub_status_leading_period_removed and sub_status_alias_address are returned by
getapiusage and were not read at all, so both were silently discarded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant