DEV Community

Cover image for Unmasking the Silent API Bug: Squashing Mismatched Types with Google AI
Chizi
Chizi

Posted on

Unmasking the Silent API Bug: Squashing Mismatched Types with Google AI

Summer Bug Smash: Clear the Lineup πŸ›πŸ›Ή

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

Project Overview

PayKit SDK is an open-source, unified TypeScript payment toolkit. It provides developers with one consistent API across multiple payment providers (like Stripe, Paystack, PayPal, and more). This means that switching or adding a provider doesn't require rewriting complex billing logic. Architecturally, it's structured as a pnpm monorepo with a core package (@paykit-sdk/core) and separate provider packages (like @paykit-sdk/stripe, @paykit-sdk/paystack).

My connection to PayKit is actually quite personal, despite this being my first open-source contribution. I met the creator back while i was still in school last year when paykit was just getting traction. We reconnected recently, and he asked me to audit the Paystack provider. Apparently no one had fully tested it against the real API yet, and he wanted it solid enough to eventually pitch for listing in Paystack's own integration docs.

Bug Fix or Performance Improvement

I noticed some bizarre, silent failures happening specifically with the Paystack provider implementation when trying to integrate it. The Paystack provider's unit tests were passing, but they were only testing against hand-written mock responses β€” nobody had checked those mocks actually matched what Paystack's real sandbox API returns. So I went method by method: createCustomer, createSubscription, createRefund, cancelSubscription, and the rest, running each one against a real Paystack sandbox key and comparing the real response shape to what the code expected.

That surfaced four real bugs hiding behind passing tests:

  1. A mapper mismatch that silently nulled out data. The core SDK's unwrap() helper converts API responses from snake_case to camelCase before handing them off. But the Paystack schema mappers (like Customer$inboundSchema) were written to read raw snake_case fields (customer_code, created_at, etc.). Since unwrap() ran first, those fields were already renamed to camelCase by the time the mapper looked for them β€” so it found nothing. The result: customer IDs came back undefined, and timestamps came back as Invalid Date, with no error thrown anywhere.
  2. A hardcoded currency on refunds. createRefund had a hardcoded 'NGN' currency fallback when building the refund response, which silently produced the wrong currency for any GHS, USD, or ZAR transaction.
  3. A silently swallowed cancellation failure. If Paystack's POST /subscription/disable call actually failed, cancelSubscription didn't check the response β€” it just returned a fake { status: 'canceled' } regardless, leaving the subscription still active with no error surfaced.
  4. Inconsistent timestamp formats. Paystack returns created_at from GET endpoints but createdAt from some POST/PUT endpoints, and the schema only handled one of the two.

None of this showed up in CI, because the mock data in the existing tests had (unknowingly) baked in the same wrong assumptions as the code itself.

Code

Full PR here: payrouteshq/paykit-sdk#59 β€” officially approved and merged!

Here is the core of the fix to stop the silent data loss. createCustomer before:

// BEFORE (Broken):
const customer = await this.unwrap(response, 'createCustomer');
return Customer$inboundSchema(customer); // schema fails to find customer_code
Enter fullscreen mode Exit fullscreen mode

unwrap() applies _toCamel, which renames customer_code β†’ customerCode, first_name β†’ firstName, and so on β€” but Customer$inboundSchema is written to read the raw, un-renamed Paystack field names. So by the time the schema ran, every field it looked for had already been renamed out from under it.

After β€” a new _throwIfFailed helper that checks the response succeeded but skips the camelCase step entirely, handing the mapper the raw payload it actually expects:

// AFTER (Fixed):
// Do NOT use unwrap() here β€” it applies _toCamel which renames customer_code
// to customerCode, first_name to firstName, etc., breaking Customer$inboundSchema.
return Customer$inboundSchema(
  this._throwIfFailed(response, 'createCustomer'),
);
Enter fullscreen mode Exit fullscreen mode
/**
 * Like unwrap() but does NOT apply _toCamel β€” use this when the result will
 * be handed directly to a provider-specific mapper (Customer$inboundSchema,
 * Subscription$inboundSchema, Refund$inboundSchema, etc.) that reads raw
 * snake_case field names from the Paystack API response.
 */
private _throwIfFailed<T>(
  result: { ok: boolean; value?: PaystackResponse<T>; error?: unknown },
  operation: string,
): T {
  if (!result.ok || !result.value?.status) {
    throw new OperationFailedError(operation, this.providerName, {
      cause: new Error(result.value?.message ?? JSON.stringify(result.error) ?? 'Unknown error'),
    });
  }
  return result.value!.data;
}
Enter fullscreen mode Exit fullscreen mode

The same swap was applied everywhere unwrap() was incorrectly feeding a raw-field-expecting mapper: updateCustomer, createSubscription, cancelSubscription, and createRefund.

My Improvements

To completely clear the lineup of these bugs, I took a multi-step architectural approach:

1. Stopped double-processing the response. For create/update methods, I replaced the generic unwrap() call with a new _throwIfFailed() helper that checks for a failed response but passes the raw snake_case payload straight to the Zod mapper, instead of letting unwrap() camelCase it first. That alone fixed the undefined-IDs and invalid-timestamp bugs across every affected method.

2. Made refund currency dynamic. createRefund now reads the currency directly off the actual Paystack refund response instead of assuming NGN, so refunds on other supported currencies map correctly.

3. Added a real failure guard on subscription cancellation. If the disable call comes back unsuccessful, the method now throws an OperationFailedError instead of quietly reporting success.

4. Normalized the timestamp handling. The schema now accepts both formats Paystack actually sends, depending on which endpoint responded.

5. Closed the test gap for real. To ensure these bugs never return to the stage, I added 27 new unit tests covering every previously-untested method (customer, subscription, payment, refund, checkout). Each is mocked with response shapes I'd actually verified against the live sandbox β€” not guessed. Every single provider method now has comprehensive coverage.

One decision I want to call out. After I thought everything was done, I double-checked myself, and Antigravity, before pushing β€” more on that below.

Best Use of Google AI

I used the Antigravity AI Agent powered by Google Gemini as an active pair-programmer for this massive debugging and refactoring session, not just autocomplete. Two moments from the actual session stand out.

I asked it to double-check itself, and it found a real gap. After Gemini told me the work was fully done β€” bugs fixed, tests passing, changeset written β€” I pushed back with a plain "hold on, has everything been done?" It re-checked its own output against the plan it had written earlier and found that it had listed createRefund in the smoke-test sequence in PLAN.md, but never actually implemented that case in the script. It added the missing test (with a try/catch to handle Paystack's "transaction already fully refunded" response, since sandbox transactions can only be refunded once) and amended the commit before I moved on. I didn't spot that gap myself β€” I caught it by making a habit of questioning "done," and the agent did the actual re-verification.

It caught a downstream detail I had forgotten to ask for. Partway through wrap-up, I remembered the maintainer had also asked for the Paystack docs in apps/docs to be updated to match the fixes. The Gemini-powered agent updated paystack.mdx for the corrected metadata/reason fields, and on its own noticed a subtler consequence: since merchant_note is now populated from reason internally, it adjusted the type so merchant_note shows correctly as optional in provider_metadata β€” and committed that as its own separate, scoped commit rather than folding it silently into the bug-fix commit.

Live Sandbox Diagnostics: It was also extremely instrumental in executing curl requests to the live Paystack sandbox right inside my terminal. We used this to analyze the JSON shapes and pinpoint the exact mismatch between _toCamel and the Zod schemas.

Ultimately, what made this collaboration so powerful wasn't just the AI writing codeβ€”it was the synergy between human oversight and agentic execution. The Gemini-powered agent didn't proactively catch those two gaps on its own. I had to drive the architecture and prompt it to re-verify its work. But once I pointed it in the right direction, it executed the fixes with incredible speed and precision. For my first open-source contribution, having an AI partner that flawlessly handles the heavy lifting while I steer the ship is exactly what made this massive PR possible in the time I had.

Top comments (0)