Skip to content

fix: strict compliance with API and webhook security spec - #6

Open
danieldenis01 wants to merge 4 commits into
AbacatePay:mainfrom
academia-ruby:fix/strict-spec-compliance
Open

fix: strict compliance with API and webhook security spec#6
danieldenis01 wants to merge 4 commits into
AbacatePay:mainfrom
academia-ruby:fix/strict-spec-compliance

Conversation

@danieldenis01

@danieldenis01 danieldenis01 commented May 3, 2026

Copy link
Copy Markdown

Small fixes uncovered while building a pay-rails/pay adapter on top of the SDK and exercising it against the AbacatePay sandbox and production. All low-risk, no public API changes — defaults align the SDK with what the API and the docs actually require today.

1. Compact nil keys before sending JSON payloads

The current API rejects requests like { "cellphone": null } or { "description": null } with HTTP 400:

Expected property 'cellphone' to be string but found: null

The clients build payloads with optional fields and pass nil straight through. Adding .compact on the request hash matches what the API actually accepts. Affected:

  • clients/customer_client.rb#create
  • clients/product_client.rb#create

This was hit in production-style flows where a Pay::Customer had no phone_number set, and where one-time products were created without a description.

2. HMAC webhook signature: hex → base64

docs.abacatepay.com/pages/webhooks/security specifies HMAC-SHA256 base64-encoded in the X-Webhook-Signature header. The previous implementation used OpenSSL::HMAC.hexdigest, which never matched the signatures the AbacatePay platform actually sends, so verify! rejected every real delivery as invalid.

3. Default the HMAC key to AbacatePay's documented PUBLIC_KEY

The same security doc states webhooks are signed with a fixed public key (not the per-webhook secret you configure in the dashboard — that one goes in the ?webhookSecret= query parameter). The constant is exposed as AbacatePay::Webhooks::PUBLIC_KEY and is now the default for verify! / valid?, so callers that just want to verify a delivery don't have to know about it. The secret: kwarg still works for tests and for any future per-webhook signing scheme.

4. Recognize abc_prod_ production keys (and stop masking API errors)

Configuration#api_url selects the API version (v1/v2) from the token prefix. It matched abc_dev_ and abc_live_ → v2, but production keys are issued with the abc_prod_ prefix, which fell through to v1. Since the product endpoints (POST /v2/products/create, etc.) only exist on v2, every production call to them came back as:

API error: Not found

…while the exact same code worked in sandbox (abc_dev_ → v2). Widened the matcher to /\Aabc_(dev|live|prod)_/ so production keys resolve to v2 too.

While here, Client#request wrapped every StandardError — including the ApiError it raises itself for {"error": ...} responses — into ApiError, "Unexpected error: #{e.message}", double-wrapping the message and dropping the original context. AbacatePay::Error (and subclasses) now propagate untouched; only genuinely unexpected errors get the Unexpected error wrapper. This is what made the Not found above surface as the less useful Unexpected error: API error: Not found.

Other

Adds base64 as an explicit runtime dependency. Ruby 3.4 stopped shipping it as a default gem, so require "base64" started warning (and would eventually break).

Test plan

  • bundle exec rspec — 333 examples, 0 failures
  • New spec/configuration_spec.rb covering version detection for abc_dev_ / abc_live_ / abc_prod_ (→ v2) and legacy/nil tokens (→ v1); client spec asserts API error messages are no longer double-wrapped
  • Tested end-to-end against the AbacatePay sandbox: customer create, product create, hosted checkout, webhook receipt with both query-parameter auth and HMAC header
  • Reproduced the abc_prod_ → v1 Not found against AbacatePay production, and confirmed the fix routes production keys to v2

🤖 Generated with Claude Code

Three fixes uncovered while integrating the SDK in a real-world Pay
adapter against the AbacatePay sandbox. All low-risk, no public API
changes:

1. Compact nil keys before sending JSON payloads.
   The current API rejects requests like { cellphone: null } or
   { description: null } with HTTP 400 ("Expected property X to be
   string but found: null"). The clients construct payloads with
   optional fields and let nil through. Adding .compact matches what
   the API actually accepts.
   - clients/customer_client.rb (#create)
   - clients/product_client.rb  (#create)

2. HMAC webhook signature: switch from hex to base64.
   https://docs.abacatepay.com/pages/webhooks/security specifies
   HMAC-SHA256 base64-encoded. The previous verify! used
   OpenSSL::HMAC.hexdigest, which never matched real deliveries.

3. Default the HMAC key to AbacatePay's documented PUBLIC_KEY.
   The doc says webhooks are signed with a fixed public key (not the
   per-webhook secret). Exposed as AbacatePay::Webhooks::PUBLIC_KEY;
   verify!/valid? now default to it. Callers can still override
   secret: for tests.

Adds base64 as an explicit runtime dependency (Ruby 3.4 no longer
ships it with the default gems).

Suite: 327 examples, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ChristoPy
ChristoPy requested a review from matheuscarddoso May 14, 2026 14:27
danieldenis01 and others added 3 commits June 6, 2026 14:30
…Error

AbacatePay production V2 keys use the `abc_prod_` prefix, which the version
heuristic in Configuration#api_url didn't recognize — only `abc_dev_` and
`abc_live_`. Production keys therefore resolved to v1, and v2-only endpoints
(e.g. POST /v2/products/create) returned {"error":"Not found"}. Sandbox keys
(abc_dev_) were unaffected, so the bug only surfaced in production.

Also stop the generic `rescue StandardError` in Client#request from
re-wrapping AbacatePay::Error (the ApiError raised just above) as
"Unexpected error: ...", which hid the real cause in Sentry.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The live API returns the copy-paste code as `brCode` and the image as
`brCodeBase64` (data URI), plus `expiresAt`/`platformFee`/`receiptUrl` —
the resource only knew the legacy `qrCode`/`qrCodeImage` names and
silently dropped the real fields. Expose the new attributes and keep
`qr_code`/`qr_code_image` as fallbacks so both shapes work.

Co-Authored-By: Claude Opus 5 <[email protected]>
The API rejects the id in the JSON body ("Expected property 'id' to be
string but found: undefined") — it expects ?id= like #check does.

Co-Authored-By: Claude Opus 5 <[email protected]>
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