Skip to content

Fixes and additions from live API verification - #41

Merged
gregsvo merged 16 commits into
mainfrom
fix/api-verification-fixes
Oct 29, 2026
Merged

gregsvo merged 16 commits into
mainfrom
fix/api-verification-fixes

Conversation

@gregsvo

@gregsvo gregsvo commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Running the SDK against the live Postmark API showed several places where it disagreed with the real responses. This PR fixes those bugs and adds three missing endpoints. It has 16 commits, one per change, and each commit passes format, lint, types and the full test suite on its own, so please use "Rebase and merge" to keep them separate.

Fixes

# Change Why
1 send_bulk() reads Id and BulkJobStatus gains CANCELLED The call raised ValidationError after Postmark had accepted the request
2 Batch sends return failed entries instead of raising One failed entry discarded the results of every entry that was sent
3 get_delivery_stats() accepts the All entry It has no Type or TypeCode, so the call always raised
4 templates.push() accepts integer server IDs Real server IDs were rejected
5 Stats treat omitted zero counts as 0 Postmark omits empty categories, so three stats calls raised on real data
6 Opens/clicks client and OS filters are sent in snake_case They were sent in camelCase and silently ignored
7 Opens/clicks from_date/to_date warn and are no longer sent Postmark has no date filter there, so they never had an effect
8 webhooks.edit() works for partial updates Postmark requires Url on every update and verifies from the request body only

Additions

# Change
9 outbound.list_bulk_requests() and stream_bulk_requests(), with a new generic CursorPage
10 webhooks.get_statistics()
11 webhooks.verify()
12 templates.list(layout_template=...)
13 verify=False on webhooks.create() and webhooks.edit() (sent as ?verify=false)
14 status on the webhook trigger models

Commits 15 and 16 add tests for previously untested code (coverage is now 100%) and remove one unreachable branch in paginate().

Compatibility

Nothing is removed or renamed, and new arguments were added at the end of signatures. Please look at these:

  • Behaviour change: opens/clicks client and OS filters now actually filter. Callers who passed them got unfiltered results before.
  • Warning: list_opens()/list_clicks() with from_date or to_date now emit a FutureWarning. Results are unchanged. A test run with warnings turned into errors would fail on such a call.
  • Extra request: webhooks.edit() now makes one GET before the PUT. Code that mocks the HTTP layer needs a response for it.
  • Typing only: SendResponse.to/.submitted_at/.message_id and BounceTypeCount.type/.type_code are now optional, and PushTemplatesRequest server IDs are int | str. Successful calls still fill them in.

These are listed in the [Unreleased] section of CHANGELOG.md. The version bump is deliberately not in this PR; it will follow in a separate release PR.

Verification

  • 652 tests pass with 100% coverage (454 before). Lint, format, mypy and all pre-commit hooks pass.
  • The fixes were checked against the real API with throwaway test data, not just mocks. All temporary resources were removed.
  • Test fixtures use placeholder values only: no real IDs, tokens or addresses.

Notes for review

  • .secrets.baseline changed slightly: the line number of one already-approved placeholder moved (41 to 48), the timestamp updated, and the scanner added one internal filter entry on its own. No new secrets were added.
  • The merge with fix(django): close automatically opened client after send failures #39 touched CHANGELOG.md and tests/django_backend/test_backend.py; both sides were kept.

…led status

send_bulk() raised a ValidationError after Postmark had accepted the request:
the response model read the request ID as "ID", but the API returns "Id", so
the caller never got the bulk request ID back.

Read "Id" (still accepting "ID"), and expose the other fields the API returns
(subject, total_messages, percentage_completed, released_count, failed_count)
on both BulkSendResponse and BulkSendStatus.

Add BulkJobStatus.CANCELLED, which Postmark documents. FAILED is kept so
existing code that references it keeps working.
The batch endpoints answer HTTP 200 even when individual entries fail, and a
failed entry carries only ErrorCode and Message. SendResponse required To,
SubmittedAt and MessageID, so a single failed entry raised a ValidationError
and discarded the results of every entry that was sent.

Make the three fields optional so every entry comes back, in order, and the
caller checks success. This applies to send_batch() and
send_batch_with_template().

Typing only: SendResponse.to, .submitted_at and .message_id are now optional.
Successful sends still populate them.
get_delivery_stats() raised a ValidationError whenever Postmark returned its
"All" total entry, which has only a Name and a Count. Make type and type_code
optional.

Typing only: BounceTypeCount.type and .type_code are now optional.
Server IDs are integers, but PushTemplatesRequest typed them as str, so a real
server ID raised InvalidEmailException before any request was made. Postmark
accepts either form, so accept int or str and send the value as given.

Typing only: the two ID fields are now int | str.
Postmark leaves out any category whose count is zero, for example a day with
only desktop clicks has no Mobile or Unknown entry. The per-day models required
every field, so platform_usage(), browser_platform_usage() and click_location()
raised on real data, and the other day models were exposed to the same problem.

Default every per-day count to 0, and every days list to empty.
list_opens() and list_clicks() sent client_name, client_company, client_family,
os_name, os_family and os_company in camelCase. Postmark ignores unknown query
parameters, so these filters silently did nothing and always returned
unfiltered results. Send the names the API understands.

Behaviour change: calls that pass these filters now return filtered results.
The Python argument names are unchanged.
Postmark has no date filter on the opens and clicks endpoints, so the
from_date and to_date arguments of list_opens() and list_clicks() never had
any effect. Keep accepting them so existing calls do not break, but stop
sending them and emit a FutureWarning that points at the caller.

Behaviour change: a call that passes either argument now warns. Results are
unchanged.
The docstring promised that only the fields you pass are changed, but
PUT /webhooks/{id} requires Url and verifies the endpoint using only the
request body, so an edit without the URL failed, and an edit of a webhook that
uses Basic auth failed verification.

Read the current webhook first, merge the requested changes into it (triggers
field by field, dropping the read-only per-trigger Status) and send the
complete configuration.

Behaviour change: edit() now makes one extra GET before the PUT.
Add GET /email/bulk. Unlike the other lists it paginates with an opaque cursor
rather than offsets, so add a generic CursorPage (items and next_key) and have
list_bulk_requests() return it. stream_bulk_requests() follows the cursor.

count is validated to 1-500 before any request: Postmark answers 0 or a
negative count with an HTTP 500, which the client would retry.
Add GET /webhooks/{id}/statistics: delivery metrics over the last 24 hours,
overall and per trigger, plus the verification status of each enabled trigger.
Averages are optional because Postmark omits them when there was no traffic or
no retries.
Add POST /webhooks/{id}/verify, which sends a test event for every enabled
trigger and reports how the endpoint answered. A failed verification is
returned with success=False and per-trigger details, as Postmark answers
HTTP 200, and is not raised.

The docstring notes that each call sends real requests and that timeouts are
retried by the client, which would send them again.
Add the layoutTemplate query parameter, which restricts the list to templates
that use the Layout template with a given alias. It is the last parameter, so
existing positional calls are unaffected.
By default Postmark verifies the endpoint before saving a webhook and refuses
to save it if the test events fail. The way to skip that is the query
parameter ?verify=false; a Verify field in the body is silently ignored.
Add verify=True to create() and edit(), and send ?verify=false when it is
False. The webhook is then saved with its triggers unverified and receives no
events until verify() succeeds.
Postmark reports "verified" or "unverified" on each enabled trigger, but the
trigger models dropped it. Add an optional status to all six. Disabled
triggers have none.
Add tests for the remaining untested code paths: inbound and server edit
filters, the suppression batch limit, exception fallbacks by HTTP status,
error-code parsing, pagination, PATCH and DELETE delegation on both clients,
the retry-loop guard, closing the sync clients, the Django backend's no-op
paths and the package version fallback.
paginate() yields every item it fetches and then advances the offset by that
many, so the offset always equals the number of items yielded, and
max_records is capped at 10,000. offset + current_limit can therefore never
pass the 10,000 limit and the guard could never run. Remove it and add a test
that the final request still ends at exactly 10,000.
@gregsvo
gregsvo merged commit 3026f9a into main Oct 29, 2026
14 checks passed
@gregsvo
gregsvo deleted the fix/api-verification-fixes branch October 29, 2026 21:04
@gregsvo gregsvo mentioned this pull request Oct 29, 2026
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