Summary
Resend::Request#process_response symbolizes response keys with data.transform_keys!(&:to_sym) (lib/resend/request.rb:75), but transform_keys! is shallow. Only the top-level Hash is converted — any Hash nested inside the response keeps its String keys.
That produces an inconsistency between response shapes:
- flat responses (e.g.
Resend::Domains.get) → all keys are Symbols
- responses containing a nested
data array (list endpoints and batch endpoints) → top level is Symbols, each nested entry is Strings
So this returns nil:
Resend::ApiKeys.list[:data].first[:id] # => nil
Resend::ApiKeys.list[:data].first["id"] # => "..." (works)
Verified against the live API on v1.6.0. For a list call the top-level keys come back as [:object, :has_more, :data] while each entry's keys are ["id", "name", "created_at", "last_used_at"].
This is not limited to list endpoints. Any nested Hash is affected, including batch responses. Live output from Resend::Suppressions::Batch.add:
3 BATCH ADD sent=3 returned=2
nested key access: entry[:id]=nil entry["id"]="019fa3b0-ac48-721e-ac97-b2ffb121a18b"
So callers of the batch APIs hit the same thing when reading id/deleted off the returned entries.
Why the specs don't catch it
The specs stub Resend::Request#perform and return Symbol-keyed Hashes:
resp = { object: "list", has_more: false, data: [ { id: "...", name: "..." } ] }
allow_any_instance_of(Resend::Request).to receive(:perform).and_return(resp)
Because perform is stubbed, process_response never runs, so the stub bypasses the very code that would have converted keys — and asserts a shape real HTTP never produces for nested entries. The suite is green while the documented/expected access pattern is wrong.
There's a second-order effect worth noting: process_response has a branch specifically for mocks (resp.respond_to?(:parsed_response) ? resp.parsed_response : resp, request.rb:73), which makes stubbed Hashes flow through differently from real HTTParty::Response objects. That's the seam where test and production behaviour diverge.
Repro
require "resend"
Resend.api_key = ENV["RESEND_API_KEY"]
res = Resend::ApiKeys.list
puts res.keys.inspect # => [:object, :has_more, :data]
puts res[:data].first.keys.inspect # => ["id", "name", "created_at", "last_used_at"]
puts res[:data].first[:id].inspect # => nil <-- surprising
puts res[:data].first["id"].inspect # => "..." <-- required
Impact
Callers reasonably assume Symbol keys throughout, since that's what flat get responses and every spec fixture show. Reading entry[:id] silently yields nil rather than raising, so it tends to surface downstream as a confusing nil rather than at the access site.
Possible directions
- Deep-symbolize in
process_response — makes the API self-consistent, but is a breaking change for anyone who already worked around this with String keys. Would want a minor/major bump and a changelog note.
- Leave the runtime behaviour as-is and fix the specs so nested entries use String keys, making the suite reflect reality. Non-breaking, and stops the fixtures from teaching the wrong shape.
- Both: fix the specs now to document actual behaviour, and treat deep symbolization as a separate deliberate breaking change.
Option 2 is the minimum I'd suggest, since right now the fixtures actively assert something the API doesn't do.
Happy to send a PR for whichever direction you prefer.
Notes
Summary
Resend::Request#process_responsesymbolizes response keys withdata.transform_keys!(&:to_sym)(lib/resend/request.rb:75), buttransform_keys!is shallow. Only the top-level Hash is converted — any Hash nested inside the response keeps its String keys.That produces an inconsistency between response shapes:
Resend::Domains.get) → all keys are Symbolsdataarray (list endpoints and batch endpoints) → top level is Symbols, each nested entry is StringsSo this returns
nil:Verified against the live API on v1.6.0. For a list call the top-level keys come back as
[:object, :has_more, :data]while each entry's keys are["id", "name", "created_at", "last_used_at"].This is not limited to list endpoints. Any nested Hash is affected, including batch responses. Live output from
Resend::Suppressions::Batch.add:So callers of the batch APIs hit the same thing when reading
id/deletedoff the returned entries.Why the specs don't catch it
The specs stub
Resend::Request#performand return Symbol-keyed Hashes:Because
performis stubbed,process_responsenever runs, so the stub bypasses the very code that would have converted keys — and asserts a shape real HTTP never produces for nested entries. The suite is green while the documented/expected access pattern is wrong.There's a second-order effect worth noting:
process_responsehas a branch specifically for mocks (resp.respond_to?(:parsed_response) ? resp.parsed_response : resp, request.rb:73), which makes stubbed Hashes flow through differently from realHTTParty::Responseobjects. That's the seam where test and production behaviour diverge.Repro
Impact
Callers reasonably assume Symbol keys throughout, since that's what flat
getresponses and every spec fixture show. Readingentry[:id]silently yieldsnilrather than raising, so it tends to surface downstream as a confusingnilrather than at the access site.Possible directions
process_response— makes the API self-consistent, but is a breaking change for anyone who already worked around this with String keys. Would want a minor/major bump and a changelog note.Option 2 is the minimum I'd suggest, since right now the fixtures actively assert something the API doesn't do.
Happy to send a PR for whichever direction you prefer.
Notes
transform_keys!call.