Skip to content

feat: v2 validate parity (verify_plus, activity_data, batch timeout) - #44

Merged
franciscbalint merged 20 commits into
masterfrom
feature/api-parity-p1-validate
Oct 24, 2026
Merged

franciscbalint merged 20 commits into
masterfrom
feature/api-parity-p1-validate

Conversation

@franciscbalint

@franciscbalint franciscbalint commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Priority 2 — real-time AI scoring

  • scoreEmail (GET) and scoreEmailPost (form POST) on /v2/scoring.
  • ZBScoreEmailResponse.

Priority 3 — bulk S3 / finder / filters

  • listS3Files, validate/score S3, email finder, filters, list evaluator (see branch diff).

Polish (latest)

  • PHPUnit testListS3FilesCallsBulkListS3files (mock lastRequestUrl + JSON array response).

Test plan

  • testScoreEmail in PHPUnit suite
  • testListS3FilesCallsBulkListS3files

Validate options, Inbox Placement Tester, list evaluator base URL

Ported from the Python SDK (407f0a8, a6710e5).

Added

  • ZBValidateOptions gains domainInfo, greylistProcessing, callbackUrl. Booleans go on the wire as "true"/"false" on both the GET and the form POST. callbackUrl is rejected without greylistProcessing = true, and when blank, before any request is sent.
  • ZBValidateResponse gains domainWebsiteExists, domainRegistrantCompanyName, and message (the API's Message key, returned while a greylist retry is still running).
  • Inbox Placement Tester as its own trait (src/ZeroBounceInboxPlacement.php) on https://bulkapi.zerobounce.net/api/ipt: getInboxPlacementProviders, getInboxPlacementTestTypes, createInboxPlacementTest, getInboxPlacementTest, listInboxPlacementTests, deleteInboxPlacementTest, getInboxPlacementWebhook, setInboxPlacementWebhook. API key on the query string, camelCase JSON bodies, unset fields omitted.
  • initialize takes optional third and fourth arguments to override the list evaluator and Inbox Placement Tester base URLs.

Changed

  • List evaluator uses its own base URL (.../v2/listevaluator) instead of building the path from the bulk base; the file id is URL-encoded as one path segment. sendEvaluatedFile stays on the bulk base.

Fixed

  • An error body with a detail string (Django REST framework, used by the Inbox Placement Tester) now raises with that text.

S3 — removed, then restored

  • An earlier push removed validateS3File, scoreS3File, exportS3File, listS3Files. That was wrong: they are documented, at https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api. All of them, their helpers, the BulkApiRootUrl constant and the tests have been restored; only the parity work above remains.
  • AGENTS.md keeps the "only documented endpoints" rule and now points at where the S3 docs live.
    New: the rest of the documented bulk surface

Seven endpoints that are documented but had never been in this SDK:

All six file jobs go through one bulkRootFileJob helper, which sends has_header_row and remove_duplicates as the strings true/false and omits remove_duplicates and return_url until they are set.

Two shapes differ from their S3 counterparts and are easy to get wrong:

  • validateSFTPFile's file_name is optional — omitting it uses the input bucket from the account's settings. Every other job requires it.
  • exportSFTPFile takes export_path, not the export_bucket that exportS3File uses. Omitting it uses the export path from those settings.

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

Tests: 66 pass, 1 pre-existing skip. Offline only — no live API calls.


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 rather than by reading the docs:

  • 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.
  • The Inbox Placement Tester authenticates with Authorization: Bearer, not an api_key parameter, and its listing is GET /tests. GET /test answers Method "GET" not allowed.
  • send-evaluated-file takes the list evaluator's file_id as a form field. A file upload is rejected with file_id: This field is required.
  • The S3 and SFTP jobs require return_url and file_name.
  • The Warmup check-domain-dmarc takes an email address, not the domain the docs describe, and a failed SMTP login in test-smtp-configuration comes back as an HTTP 500.

Two bugs the live run exposed in this SDK.

  • Every GET reported HTTP 0. The http:// stream wrapper reports its status only through PHP's predefined response-header variable, which the engine makes visible to the function that called file_get_contents and to no one else. The SDK read it from a helper, so it was always empty — an error status never reached the response check, and a 404 from the list evaluator came back as a successful result. Every verb now goes through cURL, which also removes the last read of the variable PHP 8.5 deprecates, so the legacy shim is gone and the guard test can assert the stronger property across all of src/.
  • {"success": false} on an HTTP 200 was returned as data. The API answers an unknown bulk file id that way. It now raises, and ZBApiException::getStatusCode() carries the HTTP status 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, and the SMTP test masks the credentials out of every error it raises.

Also added

  • $usePost on getCredits, getApiUsage, getActivity, scoreEmail, findEmail and findEmailFormat: the same parameters form-encoded, which keeps the key out of request logs.
  • getApiUsage formats its dates. A DateTime was interpolated whole, so the request carried a locale-formatted timestamp that the API rejects and Cloudflare can block outright.
  • ZBGetFileOptions::$stream for large result files: written in chunks to a temporary file beside the download path, moved into place only when complete, with the first 64 KB kept so an error body is still recognised rather than saved as a result.
  • getListEvaluatorResult returns a typed ZBListEvaluatorResponse; everything the API sent is still on ->data.
  • Safety: https-only base URLs and request URLs, keys with whitespace or control characters rejected before they can be quoted unredacted by the transport, . and .. refused as path ids, and a once-per-client warning when a regional base URL is paired with the single global bulk host.

Testing. 112 offline tests, 1046 assertions, no skips, no network. The live suite (composer run test:live) is excluded from composer test and from CI and needs keys from the environment; the last full run with ZEROBOUNCE_LIVE_SPEND=1 ZEROBOUNCE_LIVE_CHANGES=1 was 41 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. composer audit reports no advisories.

No version bump, tag or publish.

🤖 Generated with Claude Code

franciscbalint and others added 20 commits October 18, 2026 00:51
Add ZBValidateOptions for validate/validateBatch and activity fields on responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Add form-encoded POST option on single validate (alongside GET) and GET
listS3files on bulkapi root, with unit tests and API parity doc updates.
Assert the bulk listS3files URL and parsed array response using the existing mock request harness.

Co-authored-by: Cursor <cursoragent@cursor.com>
HTML, empty, and account-level batch errors become short exceptions, column 0 and false flags are sent, and redirects are disabled. Offline tests, the changelog, and agent notes record that behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
The notes claimed sendFile sent remove_duplicate and that optional column 0 was omitted. The SDK sends column 0 and has no remove_duplicate argument on sendFile.

Co-authored-by: Cursor <cursoragent@cursor.com>
…st evaluator base URL

Ports the Python SDK's parity changes (407f0a8, a6710e5) to this SDK.

- ZBValidateOptions takes domainInfo, greylistProcessing and callbackUrl, sent
  as the strings "true"/"false" on both the GET and the form POST. callbackUrl
  without greylistProcessing = true, and a blank callbackUrl, are rejected
  before any request goes out.
- ZBValidateResponse carries domainWebsiteExists, domainRegistrantCompanyName
  and message. ZBResponse already camel-cases the API's "Message" key onto that
  last property, which a greylist pickup returns while the retry runs.
- Inbox Placement Tester as its own trait on bulkapi.zerobounce.net/api/ipt:
  providers, test types, create/get/list/delete tests, and get/set webhook. The
  API key goes on the query string rather than in the body, bodies are camelCase
  JSON, and only fields the caller set are sent.
- List evaluator requests use their own base URL rather than building the path
  from the bulk base, and the file id is URL-encoded as one path segment.
  sendEvaluatedFile stays on the bulk base. initialize's third and fourth
  arguments override both base URLs.
- An error body carrying a "detail" string (Django REST framework, used by the
  Inbox Placement Tester) raises with that text.

Removes validateS3File, scoreS3File, exportS3File and listS3Files along with
their helpers, the now-unused BulkApiRootUrl constant, tests and changelog
entries: https://www.zerobounce.net/docs/ does not document those endpoints,
and AGENTS.md now records that the SDK covers only documented ones.

Tests: 62 pass (1 pre-existing skip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They are documented, at
https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, which
lists listS3files, validateS3file, scoreS3file, exportS3file,
emailFinderS3file and domainSearchS3file on bulkapi.zerobounce.net. The
previous commit removed them on the incorrect basis that the public docs do
not cover them; only the main API reference was considered.

Restores validateS3File, scoreS3File, exportS3File and listS3Files, the
buildValidateS3Fields, postBulkRootForm and appendOptionalBool helpers, the
BulkApiRootUrl constant, the offline and legacy tests, and the changelog
entries. The parity work in the previous commit is untouched.

AGENTS.md keeps the rule that the SDK covers only documented endpoints and
now points at where the S3 documentation lives.

Not added here: emailFinderS3file and domainSearchS3file, and the SFTP
variants listed on the Integrations index, are documented but have never been
part of this SDK.

Tests: 64 pass, 1 pre-existing skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports b0444f8 from the Python SDK. These are documented but had never been in
this SDK:

- POST /emailFinderS3file and /domainSearchS3file, from
  https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api
- POST /validateSFTPfile, /scoreSFTPfile, /emailFinderSFTPfile,
  /domainSearchSFTPfile and /exportSFTPfile, from
  https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api

All six file jobs share bulkRootFileJob, which sends has_header_row and
remove_duplicates as the strings true/false and leaves remove_duplicates and
return_url out until set.

Two shapes differ from the S3 ones and are easy to get wrong:
- validateSFTPFile's file_name is optional; omitting it uses the input bucket
  from the account's settings. Every other job requires it.
- exportSFTPFile takes export_path, not the export_bucket that exportS3File
  uses, and omitting it uses the export path from those settings.

Tests: 66 pass, 1 pre-existing skip.

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 ZBValidateSubStatus::AiAgentMailbox following RoleBasedCatchAll, so
callers have a named constant to compare against.

On unknown values: ZBValidateResponse::getValue() branches on
$classKey === "sub_status", but ZBResponse::deserialize() camel-cases the key
to "subStatus" before calling it, so that branch never runs and the raw value
is assigned unchanged. Deliberately left alone: pass-through is the safer
outcome, because activating the branch would blank out any sub_status the
installed SDK predates -- including ai_agent_mailbox for anyone on an older
version. A test pins the current behaviour so it cannot regress silently.
("status" has no underscore, so its own normalisation does run.)

No sub_status_ai_agent_mailbox usage counter: it is not documented.

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 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".
- The Inbox Placement Tester authenticates with an Authorization: Bearer
  header, not an api_key parameter.
- send-evaluated-file takes the list evaluator's file_id as a form
  field, not a file upload.
- The S3 and SFTP bulk jobs require return_url and file_name; without
  them the API answers "This field is required".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The http:// stream wrapper reports its status only through PHP's
predefined response-header variable, which the engine makes visible to
the function that called file_get_contents and to no one else. The SDK
read it from a helper, where it is always empty, so every GET was
recorded as HTTP 0 and an error status never reached readJsonBody: a 404
from the list evaluator came back as a successful result.

Every verb now goes through curl, which reports the status directly.
That also removes the last read of the variable PHP 8.5 deprecates, so
the legacy shim and its two helpers are gone and the guard test can
assert the stronger property across the whole of src/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tatus

The live API answers an unknown bulk file id with an HTTP 200 whose body
says the call failed. Returning that as a result let callers read an
error payload as data.

ZBApiException now carries the HTTP status through getStatusCode(), so
callers can tell a 403 from Cloudflare apart from a 400/404 from the API
and a 429 from the Warmup rate limit. request() rebuilds rather than
rethrows so the message stays redacted and the status survives.

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. ZeroBounce
stays the validation client; ZeroBounceWarmup covers all 26 documented
endpoints, including the two CSV downloads.

Verified live. Two 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 SMTP
username and password are masked out of every error from that method.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Calls every SDK method against the real API. phpunit.xml only includes
test/, so `composer test` and CI never run it; `composer run test:live`
does, and only 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: no Inbox Placement test, no
Inbox Placement webhook, no ZB-managed Warmup onboarding. Keys come from
the environment and are redacted out of every message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parity with the Python SDK:

- getCredits, getApiUsage, getActivity, scoreEmail, findEmail and
  findEmailFormat take $usePost. The API accepts the same parameters
  form-encoded, which keeps the key out of request logs.
- getApiUsage formats its dates. A DateTime cast to a string became the
  whole locale-formatted stamp, which the API rejects and Cloudflare can
  block outright; a YYYY-MM-DD string is accepted too, anything else is
  refused before sending.
- An API key with surrounding whitespace or control characters is
  rejected, because the transport would otherwise quote it unredacted in
  its own error. An empty key stays deferred, so the existing message
  naming initialize() is unchanged.
- Every base URL must be https, and so must every URL a request is
  actually sent to.
- A file or test id of "." or ".." is refused. Everything else, slashes
  included, is still percent-encoded into one path segment.
- A regional base URL paired with the single global bulk host warns
  once per client: ZeroBounce documents no regional host for bulk
  files, the list evaluator, S3/SFTP or the Inbox Placement Tester.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getListEvaluatorResult returns ZBListEvaluatorResponse: the documented
fields are named, and everything the API sent is still on ->data, so a
field this version does not name yet is not lost.

ZBGetFileOptions gains $stream. With it, a result file is written to
disk in chunks rather than held in memory: the bytes land in a temporary
file beside the download path and are moved into place only when the
transfer completes, so a failed download leaves nothing behind. The
first 64 KB are kept in memory so an error body is still recognised and
reported as a short message rather than saved as a result file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README gains a Warmup API section, the opt-in live suite and its two
flags, the streaming download option, and the $usePost argument.
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. CHANGELOG lists
every user-visible change under [Unreleased].

composer: `lint` covers live/, the phpunit floor is the current patch
release, and composer.lock is refreshed. `composer audit` reports no
advisories.

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.

ZBResponse::Deserialize converts a snake_case key to camelCase and skips any
key with no matching property. Four counters convert to names that are not the
ones declared:

  sub_status_antispam_system             -> subStatusAntispamSystem
  sub_status_greylisted                  -> subStatusGreylisted
  sub_status_unroutable_ip_address       -> subStatusUnroutableIpAddress
  sub_status_mail_server_did_not_respond -> subStatusMailServerDidNotRespond

against properties spelled $subStatusAntiSpamSystem, $subStatusGreyListed,
$subStatusUnRoutableIpAddress and $subStatusMailServerDidNotResponse. All four
were therefore skipped and stayed null on every call. Renaming the properties
would break callers, so ZBApiUsageResponse overrides getClassKey for those
four instead.

sub_status_leading_period_removed and sub_status_alias_address are returned by
the API and had no property at all; both are now declared and read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@franciscbalint
franciscbalint merged commit 4772209 into master Oct 24, 2026
6 checks passed
@franciscbalint
franciscbalint deleted the feature/api-parity-p1-validate branch October 24, 2026 19:07
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