python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
python -m pytest # or: python -m unittest discover -s tests
ruff check . && mypyThe test suite is written with unittest, so it also runs with no dependencies
via python -m unittest discover -s tests.
The SDK's core job is reproducing a byte-exact, order-sensitive HMAC that the
server rebuilds independently. The server signs
base64(hmac_sha256(implode('', Arr::except($request->validated(), [...])))), and
Laravel's validated() returns fields in rules() order, skipping absent
ones.
Because the SDK uses snake_case parameters that match the wire names, the order
of fields in src/paylink/_field_orders.py must match the order of keys in the
endpoint's FormRequest rules(), minus token/signature (and
payment_mode, which is sent with signed=False).
When you touch an endpoint:
- Open the matching FormRequest under
app/Http/Requests/Application/ExternalPaymentIntegration/in the PayLink repo and readrules()top to bottom. - Mirror that order in the
EndpointSpec.build_signed_bodysigns the values in that order — order is not something the type checker can verify for you. - Add a golden vector (below).
tests/golden-signatures.json is shared with the JS SDK and generated by PHP
using the same primitive the server uses, with no Laravel or DB dependency. It is
what proves the Python, JS, and PHP implementations agree byte-for-byte.
Each case pairs a camelCase input with values (the exact ordered
concatenation the server builds) and the expected signature. test_golden_signatures.py
asserts both that build_signature(values) produces expected and that
build_signed_body derives the same signature from structured input.
Adding a field to an endpoint without adding a golden case means that field's position is untested.
Requests sign by opt-in: _field_orders.py lists exactly what gets signed.
Webhooks are the opposite. PaymentIntegrationWebhookJob copies the whole
payload and unset()s a fixed exclusion list before hashing, so a new webhook
field is signed if it is added before that unset() and unsigned if added after
(as auth_code is).
_OPTIONAL_SIGNED in src/paylink/webhooks.py must mirror that decision, and
because presence is detected with in payload, it is a strict either/or:
| Server behaviour | _OPTIONAL_SIGNED |
|---|---|
| Signs the field (added before unset) | must list it |
| Sends it unsigned (added after) | must not list it |
Getting it backwards breaks verification for every webhook carrying that field.
Requests are only ever replayed when replaying cannot double-charge: GETs,
anything carrying an Idempotency-Key, and calls explicitly flagged
replay_safe (pure reads such as check-status). The default for a POST is "do
not retry"; override it only deliberately.
- Explicit type hints; the package ships
py.typedand is checked withmypy --strict. - Keep runtime dependencies at zero — the standard library only.
- Prefer a docstring explaining why over an inline comment restating what.
- Bump the version in two places — they must agree:
[project].versioninpyproject.toml, and__version__insrc/paylink/_version.py(the User-Agent would otherwise report a stale version).
- Update
CHANGELOG.md. - Tag the release
v<version>and publish to PyPI.