Typed SDKs for partner APIs with a stable internal contract
Typed SDKs for partner APIs help you keep vendor quirks out of your product. Learn to define a thin internal contract and swap providers safely.

Why vendor-shaped data causes trouble
Typed SDKs help, but they do not solve the whole problem. Most of them give you types that match the partner response almost field for field. Your code becomes safer at compile time, but it still starts thinking in the vendor's terms instead of your product's.
The first problem is simple. Different partners often send the same fact in different shapes. One returns status: "paid". Another returns payment_state: "complete". A third hides that answer inside a nested object. Your product only needs one answer: did the customer pay? Without a clear boundary, every screen, job, and webhook handler ends up translating that answer on its own.
That gets messy fast. A small change in one partner API can spread across the app. A renamed field, an empty string instead of null, or a value moved into a new array can break search, reports, or a background sync. The change looks small in the docs, but the fix is rarely small in the code.
The deeper issue is coupling. Product code starts depending on vendor field names, enum values, and weird edge cases. Developers stop asking, "What does our product need here?" and start asking, "What did partner X call this field again?" Once that habit spreads beyond the integration layer, the center of the app gets harder to change.
Then provider swaps become expensive. You are not replacing one integration with another. You are hunting vendor assumptions through checkout flows, admin tools, tests, analytics, and support scripts. Something that should take a few days can take weeks.
A thin internal contract prevents most of this. It gives the rest of the product one stable shape to rely on. Without it, every partner decision leaks inward, and each new integration makes the codebase a little more fragile.
What a thin internal contract does
A thin internal contract gives your product a smaller, cleaner shape than the partner API. You define the fields your app actually uses and ignore the rest. It sounds basic, but it saves a lot of rework.
Most partner APIs return far more data than your product needs. You get vendor IDs, nested metadata, legacy fields, odd status codes, and naming that only makes sense inside that vendor's team. If that raw payload spreads through your app, feature code starts depending on choices your team never made.
The contract stops that spread. Checkout, billing, and reporting read your own types, not the partner response. The wrapper translates the payload once at the edge and gives the rest of the product something stable.
This is where teams often fool themselves. They generate a full client, expose every field, and call it "typed." It is typed, but it is still vendor-shaped. One rename or one nesting change can still ripple through the app.
Name the contract after your domain, not the vendor's. If your product needs a shipping quote, call it ShippingQuote, not CarrierRateReply. If you need a billing state, use InvoiceStatus, not the partner's internal enum. Clear names make the code easier to read and provider changes less painful.
Keep the boundary thin
The wrapper is a boundary, not a mirror. It should answer product questions, not preserve every detail from the partner docs.
A shipping provider might return 70 fields, but your UI may only care about service name, delivery estimate, final price, and whether tracking is available. That smaller contract gives you room to switch providers, combine providers, or patch a bad response without touching business logic.
If you need the raw payload for logs or debugging, keep it at the edge. Do not pass it into pricing rules, order flows, or customer-facing code.
This is also how small teams stay fast. The same idea shows up in Oleg Sotnikov's work at oleg.is: protect the center of the product, keep boundaries clear, and make changes in one place instead of ten.
Start from product needs, not API docs
API docs make it easy to copy the partner's world into your own code. That feels fast on day one. Later it turns into drag when the vendor adds fields, renames enums, or returns five status values your product never asked for.
Start with the product story in plain language. For a shipping flow, that usually means a short set of actions: get a rate, buy a label, cancel a label, track delivery, and show a clear failure message. That list is much smaller than the full partner API surface, and that is exactly what you want.
Your contract should match what the product does, not everything the vendor happens to expose.
Then split fields into two groups: needed now and maybe later. If checkout needs recipient address, parcel size, price, and tracking number, keep those. If the provider also returns depot codes, tax zones, routing hints, and ten timestamps that no screen uses, leave them out. Extra fields do not stay harmless. They spread into tests, database columns, and UI props.
Do the same with errors. The UI does not need a dump of raw partner failures. It needs states people can act on. A short set often works best: invalid input, no option available, temporary provider issue, and operation failed after retry. You can still log the full vendor response for support and debugging, but the app should speak in product terms.
This is where typed SDKs actually help. Their job is not only to add types around HTTP calls. They should help draw a line between unstable outside data and stable domain models inside your app.
A small example shows the difference. If one provider returns rate_id and another returns service_code, your checkout probably needs neither. It needs something like optionId, plus price, currency, and delivery estimate. That smaller shape is easier to test, easier to swap, and harder for a vendor change to break.
If a field does not support a real feature today, skip it. Add it later when the product actually needs it.
Build the wrapper step by step
Put every partner request behind one adapter. That adapter should be the only place that knows the vendor endpoint, auth rules, odd field names, and retry behavior. The rest of the app should talk to your internal contract, not the raw response.
A good wrapper follows a simple flow. Send the request through one adapter for that operation. Parse the raw response as soon as it comes back. Validate the fields you actually need. Map the vendor payload into stable domain models. Return one result shape to the rest of the app, and log anything unexpected.
That early parse matters more than it seems. Raw JSON looks harmless, but it spreads quickly. Once several parts of the codebase start reading vendor fields directly, your abstraction layer is already broken.
Keep the internal result small. If the product only needs status, price, currency, and estimatedDelivery, return only those fields. Do not pass through twenty extra properties just because they exist. Extra data creates hidden dependencies.
A shipping example makes this concrete. Provider A returns eta_days, while Provider B returns delivery_window with a start and end date. Your wrapper can turn both into one internal field like estimatedDelivery. The checkout page does not care how each partner describes it. It only needs one stable answer.
Log surprises, but do it on purpose. If a partner starts sending a new status, a missing field, or a different enum value, save that event with the raw payload and partner name. Then you can review real changes later without breaking live flows.
The approach is plain, and that is why it works. One adapter owns the mess. The rest of the app gets a clean result and stays easier to change.
Map messy responses into stable types
A stable type does not mirror the partner response. It keeps only the fields your product uses and stores them in one format. If one vendor sends weight in pounds and another sends kilograms, convert both at the boundary before the data reaches the rest of the app.
Use the same rule for money, dates, and IDs. Pick one money format, one timestamp format, and one naming style for your internal models. Then make every adapter translate into that shape. Your product code stays boring, which is exactly the goal.
Status values need the same treatment. Partner APIs often come with long status lists, mixed casing, and unclear edge states. Your app usually needs far fewer. A delivery flow might only need pending, in_transit, delivered, failed, and unknown.
That means values like IN_PROGRESS, moving, out_for_delivery, and carrier_scan_12 can all map to in_transit. You lose noise, not meaning. If a partner adds a new status tomorrow, you update one mapper instead of chasing conditionals across the codebase.
Missing data needs rules, not guesses. Decide which fields the product must have and reject the response if they are missing. Decide which fields can be optional and how the UI should behave when they are empty. Also decide when "missing" means "unknown" and when it means "not applicable." Those are different states.
Errors deserve their own boundary too. Keep partner-specific error codes, raw messages, and odd payload fragments out of shared models. Your shared error type can stay small: a code your app understands, a user-safe message, and maybe a retry flag. Save the raw vendor details in logs or debug metadata where your team can inspect them without leaking that shape into the product.
This is where the wrapper pays for itself. It absorbs vendor churn, and your stable models change only when the product changes. If a provider renames a field next week, that should be an adapter update, not a product-wide refactor.
Example: one checkout, two shipping providers
A checkout page rarely cares about every field a shipping partner sends back. It usually needs the few details that affect the buyer's choice: price, ETA, and service level. Everything else can stay inside the integration layer.
Imagine provider A returns a price in cents, a delivery date like "2026-05-12", and a service code such as "EXPRESS". Provider B sends a decimal string like "12.99", no exact date, and a time window like "2-4 business days" with a label such as "priority".
If raw responses leak into checkout code, the mess spreads quickly. One screen divides cents by 100, another parses decimal strings, and a third tries to compare a date with a time window. Small differences turn into bugs and awkward edge cases.
A thin internal contract prevents that:
type ShippingQuote = {
amount: number
currency: string
etaText: string
serviceLevel: "standard" | "express"
}
Now each provider gets its own mapper. Provider A converts 1299 to 12.99, turns the delivery date into a readable ETA string, and maps EXPRESS to express. Provider B parses "12.99" into a number, keeps "2-4 business days" as the ETA text, and maps priority to express if that is how your product presents it.
The checkout flow never sees the partner shape. It reads one ShippingQuote type every time, no matter which carrier sits behind it. That keeps pricing logic, UI labels, sorting, and tests much simpler.
This is the practical value of an internal contract. It does not pretend both providers are identical. It defines the small shared shape your product actually uses.
Later, if you replace provider B with a new carrier, most of the app stays untouched. You update one adapter, rerun integration tests, and keep the same stable models in checkout. That is what a real vendor abstraction layer looks like: thin, honest, and limited to what the product needs.
Mistakes that create a fake abstraction
A fake abstraction looks neat in a diagram, but vendor details still leak into the product. If your app still knows partner field names, error codes, and odd response shapes, the wrapper is just decoration.
The most common mistake is copying the full vendor schema into your codebase and calling it an internal model. That feels fast at first. A month later, one partner adds three optional fields, renames one enum value, and your app changes even though the product did not.
A real internal contract keeps only what the product needs. If checkout needs delivery date, shipping cost, and a tracking ID, keep those. Do not drag along forty extra fields because the partner sent them.
Teams also break the boundary by bypassing the wrapper when a deadline gets tight. One engineer calls the partner SDK directly "just for this case," then someone else copies that path, and now you have two sources of truth. The next vendor change becomes a scavenger hunt.
The warning signs show up early. UI components branch on vendor error codes. Services read raw payloads outside the wrapper. Internal types fill up with nullable fields nobody uses. Retry logic sits next to product rules.
That last one causes quiet damage. Retries, timeouts, rate limits, and idempotency belong near the integration layer. Rules like "show express shipping only for paid orders" belong in product code. Mix them together and every change gets harder to test.
Passing vendor error codes into the UI is another leak. The screen should not care whether the partner said ERR_1047 or ADDRESS_FAIL. The wrapper should reduce both to a small set of app errors such as invalid_address or service_unavailable. That keeps the interface stable and messages clearer for users.
Adding fields "just in case" sounds harmless, but it makes the contract soft. Every field you expose becomes a promise someone else may start using. Later, when you try to clean it up, teams depend on data you never meant to support.
A good abstraction is a little strict. That is the whole point. It protects stable models, keeps integration changes contained, and makes a second provider much less painful to add.
Checks before release
A wrapper is ready for production when your team can describe it without opening the partner docs. If that takes more than a page, the contract is probably still too close to the raw response.
Most breakage starts outside your product. A partner renames one field, changes an enum, or starts sending null where it used to send a string. The rest of the app should feel none of that outside the mapper layer.
Keep the release checks short and strict:
- Ask one engineer to explain the internal contract on a single page in plain language.
- Swap partner fixtures in tests without touching product code.
- Reduce partner failures to a small set of app states, not raw vendor text.
- Delete or rename a field in a fixture on purpose and make sure mapper tests fail immediately.
- Log the raw response and the mapped result with the same request ID, while hiding secrets.
A small example makes the point clear. Say your app expects deliveryEtaDays, but one provider sends eta_days and another sends estimated_delivery. Product code should never care which name the partner picked. Tests should catch the mismatch where the response is translated, not later in checkout.
Also test the ugly cases before release, not just the happy demo flow. Force a timeout. Send a partial payload. Return an unknown status. If the app still lands in a clear state each time, the contract is doing its job.
When these checks pass, the wrapper is probably small enough, strict enough, and boring enough to trust in production.
What to do next
Start with one partner flow that already changes too often. Shipping quotes, tax checks, identity checks, and payouts are common trouble spots because fields drift and edge cases pile up. Wrap one flow first, ship it, and learn from real usage before touching every integration.
Keep the first contract thin. If the product only needs status, amount, currency, reference ID, and error reason, stop there. A small contract is easier to review, test, and keep stable when a vendor adds ten more fields you do not care about.
Bring product and engineering into the same review. Product can say which states matter to users and support. Engineering can say which fields are safe, which ones are noisy, and where provider quirks will leak through if you are not strict.
A simple checklist helps. Write internal type names in product language, not vendor language. Keep vendor IDs and raw payloads at the edge. Remove provider names from shared models, events, and UI labels. Add tests for mapping, missing fields, and status changes. Log raw responses so the team can debug without changing the contract.
This is where teams often slip. A field called carrier_code or partner_status looks harmless, but once it reaches shared code, the vendor shape starts steering the product. The UI picks up provider terms, and replacing that provider later gets expensive.
If you are building typed SDKs for partner APIs, treat the wrapper like product code, not glue code. Give it an owner, version it carefully, and change it when the product need changes.
A small milestone is enough to prove the approach. Put one provider through the new wrapper, make one product screen read only internal models, and update one support workflow to use the new status names.
If your team wants another set of eyes on that contract or rollout plan, Oleg Sotnikov at oleg.is does this kind of Fractional CTO advisory work with startups and smaller companies. It is a practical fit when you need clearer architecture decisions without turning the process into a heavy project.
Frequently Asked Questions
What is a thin internal contract?
A thin internal contract is the small set of fields and states your product actually uses. Your adapter translates each vendor response into that shape once, so the rest of the app reads your models instead of partner payloads.
Why are typed SDKs not enough on their own?
Typed SDKs only make the vendor response easier to call and type-check. They do not stop your product code from depending on vendor field names, enum values, and odd response shapes.
Which fields should I keep in the internal model?
Start with the product action, not the API docs. Keep only the fields that support a real screen, rule, report, or support task today, and leave the rest at the edge.
Should I store the raw vendor response in product code?
Keep raw payloads in the adapter layer for logs and debugging. Do not pass them into checkout, pricing rules, reporting, or UI code, or the boundary will leak fast.
How should I handle different status values from different providers?
Pick one internal status set and make every provider map into it. That gives your app one clear answer, such as pending, in_transit, or failed, even when vendors use very different terms.
Should the UI ever see vendor error codes?
No. The UI should see a small set of app errors like invalid_address or service_unavailable, while your logs keep the partner code and raw message for support work.
What should one adapter be responsible for?
One adapter should own the endpoint details, auth, retries, parsing, validation, and mapping for that partner operation. Product code should call the adapter and receive one stable result shape back.
How can I tell if my abstraction is fake?
A fake abstraction still lets vendor names and response details leak into shared code. If components branch on partner codes or services read raw fields outside the wrapper, the contract is not doing its job.
How do I test this before release?
Use mapper tests that fail as soon as a vendor changes a field, enum, or null behavior. Also run ugly cases on purpose, like timeouts, partial payloads, and unknown statuses, and make sure the app still lands in a clear state.
Where should I start if my app already uses vendor-shaped data everywhere?
Start with one flow that already causes churn, like shipping quotes or payouts. Wrap that path first, keep the contract small, and prove that one screen can run only on internal models before you expand the pattern.