Oct 23, 2025·7 min read

Shared API contracts that keep web and mobile in sync

Learn how shared API contracts lock down enums, pagination, and error shapes so web and mobile teams ship with fewer client-side workarounds.

Shared API contracts that keep web and mobile in sync

Why clients keep branching around the same API

Clients start branching when the API says the same thing in slightly different ways. One endpoint returns paid, another returns PAID, and a third sends 2. That looks minor on the backend. On the client, it turns into extra checks, fallback labels, and another place where a screen can fail.

Web and mobile teams usually patch these gaps in different ways because they work under different limits. A web team can add a quick mapper in the browser and move on. A mobile team often has to ship the workaround through app review, so the defensive code stays around much longer.

The pattern repeats everywhere: enums change names or types, pagination switches between page numbers and cursors, and error bodies use different fields on each endpoint. After a while, every new screen collects a little extra logic. Developers stop trusting the response shape, so they code for surprises instead of the contract.

That changes user behavior too. One client might treat an unknown enum as "other." Another might block the action and show an error. Same backend response, different outcome.

The cost shows up quickly. QA has to test edge cases per client instead of per feature. Releases slow down because one backend change creates two client fixes. Support gets noisier because users report inconsistent results even though everyone is talking to the same service.

An orders flow makes the problem easy to see. If the list endpoint paginates one way, the detail endpoint uses different status values, and checkout failures return a different error shape, both clients branch three times before the feature feels stable. Small mismatches add up fast.

What to standardize first

Start with the parts of the API that both clients touch all the time. If web and mobile both read id, status, title, total, or updated_at on the same screens, those fields should behave the same everywhere. Small differences in common fields create the most branching, and that branching spreads.

A good first pass is simple. List the screens both clients use every day. Mark the fields each screen actually reads, not just everything the backend returns. Then split those fields into two groups: the ones that can change and the ones that need to stay fixed. Begin with endpoints that already cause repeat bugs or support tickets.

That second step matters more than teams expect. Many APIs return 30 fields, but clients may depend on only 6. Standardize those 6 first. If an order screen always needs order_id, status, currency, total_amount, and created_at, lock those down before spending time on rarely used extras.

Then decide what can vary. Labels can change. Optional notes can be empty. Status codes, page rules, and null behavior usually should not drift between endpoints. If one endpoint sends status: "paid" and another sends status: "completed" for the same state, clients will add special cases and keep them for years.

Write one source of truth for names, types, and null rules. It can be a schema file, a typed contract in the repo, or a short contract document that both backend and client teams actually use. The format matters less than the habit: one place, reviewed by everyone, updated before code ships.

Do not try to fix the whole system in one pass. Pick the two or three endpoints that break most often, write the contract for those, and make new changes follow it. That gives the team a pattern they can repeat without a big rewrite.

Set clear rules for enums

Enum values should read like internal codes, not screen text. If the app shows "Paused," "On hold," or a translated label later, the API should still send one stable value such as on_hold. That keeps web and mobile teams from tying business logic to wording that product or marketing may change next week.

Pick values that are boring and durable. Lowercase snake_case is easy to scan and hard to misread. Avoid spaces, punctuation with meaning, and labels like "Plan A" that only make sense on one screen.

Old clients will eventually see a value they do not know. Plan for that before it happens. Every client needs a safe fallback: show a neutral label, hide actions that depend on a known state, and log the event. Crashing, spinning forever, or guessing the wrong state costs more than showing "Unknown."

Renaming enum values is where teams usually create pain. If awaiting_docs becomes waiting_for_documents, treat that as a new value, not a quick rename. Keep the old value readable until every active client version stops sending or expecting it. In practice, that often means one or two release cycles, and sometimes longer on mobile.

It also helps to decide who can change an enum and how that change ships. A small policy is enough. Product defines the new business state and when it appears. The backend owner adds it to the contract and marks old values as deprecated instead of deleting them. Web and mobile owners add handling for the new value and the unknown fallback. The team removes a deprecated value only after usage drops to zero.

That bit of discipline keeps the contract calm instead of fragile. Clients stop branching around surprises, and backend changes stop turning into patch releases.

Pick one pagination format

Pagination gets messy when one endpoint uses page=2, another uses offset=20, and a third returns a cursor. Then web and mobile teams start adding exceptions everywhere.

Choose one approach for the whole product and stay with it. Page numbers work well for simple admin lists and data that does not change much between requests. Cursors work better for feeds, timelines, and anything that can shift while the user is scrolling. Either option is fine. Mixing both across similar endpoints is what causes trouble.

The format should stay the same every time clients fetch a collection. Use the same field for the item limit, the same place for the next page value, and the same rule for total counts. If you return a total count, return it in the same field on every paginated response. If counting is too expensive on some endpoints, it is often better to skip totals everywhere than to make clients guess when they will appear.

Clients also need one clear rule for the first request. With page numbers, that usually means page=1 and a chosen limit. With cursors, define whether the first request sends no cursor at all or sends a fixed empty value. Do not leave that up to each backend handler.

A paginated response usually needs only a few stable fields:

  • items for the results
  • limit for page size
  • next_cursor or page for what comes next
  • total if you support it consistently

Empty results should not change the structure. Return items: [], keep the pagination fields, and make the next page value clearly empty, such as null. Do not switch to items: null, and do not drop metadata just because there is nothing to show.

That one decision saves a surprising amount of client code. A mobile developer should not need endpoint specific pagination logic just to render the next screen.

Keep error shapes predictable

Bring in Fractional CTO Help
Get senior technical advice on API contracts, rollout rules, and product architecture.

When error responses change shape from one endpoint to the next, web and mobile teams start writing special cases. One screen checks error.message, another checks errors[0], and a third falls back to a generic alert. That cleanup work adds up quickly.

Every error should use the same top level fields, even when the HTTP status changes. A 400, 401, 404, or 500 can still follow one structure. Clients can parse errors once and reuse the same handling everywhere.

A simple format is usually enough:

  • code for a stable machine readable error code
  • message for text a person can read
  • retryable to tell the client if retrying makes sense
  • user_fixable to show whether the user can solve it
  • fields for form errors, using the same shape every time

Keep code and message separate. The code should stay stable for logic, analytics, and tests. The message can change for tone, wording, or translation without breaking clients. If mobile wants to show a local message for EMAIL_ALREADY_USED, it can do that safely.

Field errors need the same discipline. Do not return a map on one endpoint and an array on another. Pick one format and keep it. An array is often easier to extend later.

{
  "code": "VALIDATION_ERROR",
  "message": "Some fields need attention.",
  "retryable": false,
  "user_fixable": true,
  "fields": [
    { "field": "email", "code": "INVALID_FORMAT", "message": "Enter a valid email address." },
    { "field": "password", "code": "TOO_SHORT", "message": "Password must be at least 8 characters." }
  ]
}

This also helps with background failures. If a timeout returns retryable: true, the app can show "Try again" instead of blaming the user. If a permission error returns user_fixable: false, the client can stop asking for input the user cannot change.

One small rule removes a lot of branching: keep the body shape steady, and let the values explain what happened.

Roll out the contract step by step

Do not try to fix every endpoint in one sprint. Shared contracts usually fail when teams go too wide, too early, and get stuck arguing over edge cases before anything ships.

Start where client code is already messy. Look for endpoints that make web and mobile teams write special cases, map odd enum values, or guess what an error means. If three screens all carry the same workaround, that endpoint belongs near the top of the list.

Before anyone changes code, write the contract in plain language. Keep it short. A good draft says things like "status uses these exact values," "lists always return the same pagination fields," and "every error includes code, message, and details." If a product manager can read it without help, you are probably on the right track.

Then get backend, web, and mobile in one room and review the draft together. That meeting matters because each team sees a different kind of breakage. Backend engineers think about data shape, web engineers see rendering issues, and mobile engineers spot versioning pain earlier than anyone else.

A rollout like this tends to work well:

  1. Pick one feature with real traffic but limited scope, such as order history or notifications.
  2. Apply the contract there first and keep the old behavior behind a version flag or adapter.
  3. Watch what breaks in clients, tests, logs, and support tickets.
  4. Adjust the wording where people interpreted the rules differently.
  5. Move the same rules to the next feature only after the first one settles down.

This pilot phase saves time. Teams often learn that the contract looked clear on paper but still left room for two meanings. It is much cheaper to fix that after one feature than after twenty.

Before each release, run automated checks against the contract. Schema tests, enum validation, and snapshot checks for error payloads catch drift early. If your team already uses CI, add these checks there so nobody ships a quiet format change on Friday evening.

A simple example with order status

Align Web and Mobile
Set one contract both clients can trust on every shared screen.

Picture an order system with three clients: an admin panel, a shopper app, and a driver app. If the backend sends paid to one screen, payment_received to another, and ready_for_pickup only for drivers, each client starts adding its own fixes. That is how small API quirks turn into messy code.

A shared contract keeps all three screens on the same rules. The order status enum stays the same everywhere, even if each screen shows different labels.

{
  "order": {
    "id": "ord_4821",
    "status": "packed"
  },
  "status_enum": [
    "pending_payment",
    "paid",
    "packed",
    "out_for_delivery",
    "delivered",
    "cancelled"
  ]
}

The admin screen can show "Packed and waiting," the shopper can see "Preparing your order," and the driver can ignore statuses that do not matter yet. All three clients still read the same enum value: packed. They do not need branches like "if admin use this value, if mobile use another one."

Order history should follow one pagination format too. Phone and desktop can present the list differently, but the API should answer the same way every time.

{
  "items": [{ "id": "ord_4821", "status": "delivered" }],
  "next_cursor": "eyJpZCI6IjQ4MjEifQ==",
  "has_more": true
}

That works for infinite scroll on mobile and a "Load more" button on desktop. Neither client needs special logic for page numbers in one place and cursors in another.

Error responses need the same discipline. A payment failure, an expired session, and bad input can share one shape even though the messages differ.

{
  "error": {
    "code": "payment_failed",
    "message": "Card was declined",
    "details": {
      "field": "card_number"
    },
    "retryable": false
  }
}

With that shape, every client can follow the same pattern:

  • show message to the user
  • use code for screen logic
  • read details for field errors
  • decide whether to retry from retryable

That is where the contract pays off. Web and mobile teams stop guessing what each endpoint might return, and backend changes stop breaking one screen at a time.

Mistakes teams make

Teams usually break a contract in small, harmless looking ways. Then the web app adds one workaround, the mobile app adds another, and six months later nobody trusts the API.

A common mistake is renaming enum values to match new UI copy. A backend returns in_progress for months, then someone changes it to processing because the product team updated a label. The label change is fine. The enum change is not. UI text can change every week. Contract values should stay boring and stable.

Pagination drifts in the same way. An older endpoint returns page and total_pages, while a newer one returns cursor and has_more. Both formats can work, but mixing them across similar endpoints makes clients branch for no good reason. Web and mobile teams end up writing adapter code instead of product code.

Error handling often gets worse under pressure. Many teams pack every failure into one generic message string like "Something went wrong." That feels simple on the server, but it pushes the hard work onto clients. Apps need a predictable error shape so they can tell the difference between a validation problem, an auth failure, and a temporary server issue.

Null handling causes quiet breakage. If phone used to be null when missing and now the API sends an empty string, client code may treat those cases differently. Nobody notices until search, filters, or forms start acting strange.

Unknown values are another weak spot. If the server adds a new enum later, each app should not guess what it means. Decide the fallback behavior ahead of time.

Most of the mess comes from a few habits: tying contract values to UI wording, keeping old and new pagination formats alive without a plan, hiding all errors inside one message field, changing null rules without telling client teams, and assuming clients will "figure out" new enum values.

Stable contracts can feel strict, but they save time. More importantly, they stop backend quirks from spreading into every client.

Quick checks before release

Stop Client Side Patches
Fix the backend mismatches that keep web and mobile teams writing separate logic.

A release is not ready when web and mobile still need different parsing rules for the same endpoint. The whole point of a contract is that both clients can read the same response shapes and make the same decisions.

Run one short review before release and use real sample payloads, not just docs. A contract can look fine on paper and still break when one client gets an empty list, an unknown enum value, or a 401 response with a different body.

Check a few things every time. Give the web and mobile teams the same success and failure samples and make sure both clients parse them without custom fixes. Review every enum and decide what clients should do when the server sends a new value they do not know yet. Test pagination at the edges: the first page, the last page, and an empty result. The shape should stay the same each time. Compare error responses for 400, 401, 404, and 500 and make sure the fields match even when the messages differ. Finally, write down every breaking change and the version rule the team follows so nobody has to guess whether an older app still works.

A simple test catches a lot. Take one endpoint, such as an orders list, and run four cases: normal data, no data, expired auth, and server error. If mobile needs one parser and web needs another, the contract is still too loose.

Teams often skip the unknown enum check. That is a mistake. Mobile apps can stay in users' hands for months, so they need to handle new values without a forced update.

If anyone says "it depends on the client," stop and tighten the contract before release.

What to do next

Do not start with a full API cleanup. That usually stalls, and teams slip back into old habits. Pick one contract template and use it on the next feature you ship.

Make that template boring and strict. It should define enum values, one pagination format, and one error response shape. If a new endpoint cannot fit the template, treat that as a design problem, not something each client should patch on its own.

A short review before release saves a lot of rework later. Put backend, web, and mobile in the same call for 15 to 20 minutes. Read the request and response examples together, check edge cases, and agree on what happens when data is missing, a value is unknown, or a request fails.

A simple routine works well:

  1. Write the contract before backend work is finished.
  2. Check it with web and mobile before release.
  3. Ship it on one new feature, not ten.
  4. Track every place where clients still branch around backend quirks.
  5. Remove those cases one by one in the next releases.

That last step matters. Many teams adopt contracts on paper, then keep old exceptions forever. Keep a short list of client branches, who added them, and what backend change would remove them. If one branch survives for months, the contract is probably still too loose.

This does not need a giant process. One template, one review, and a short cleanup list will get you further than a long standards document nobody reads.

If your team wants an outside review, Oleg Sotnikov at oleg.is helps startups and small teams tighten API contracts, rollout rules, and app architecture as a fractional CTO.

Frequently Asked Questions

Why do web and mobile teams keep adding branches around the same API?

Because the API returns similar data in different shapes. Once status, pagination, or errors drift between endpoints, each client adds its own fixes and those fixes stick around.

What should we standardize first?

Start with fields both clients read every day, like IDs, statuses, totals, and timestamps. Lock down the small set that drives screen logic before you clean up rarely used fields.

How should we design enum values?

Keep enum values boring and stable, like paid or on_hold. Do not use UI copy as enum values, because product text changes much faster than client logic.

What should a client do with an enum value it does not know?

Treat unknown values as normal and safe to handle. Show a neutral label, hide actions that depend on a known state, and log the event instead of guessing.

Should we use page numbers or cursors?

Pick one format for similar collection endpoints and stick to it. Use page numbers for simple, stable lists or cursors for feeds that shift often, but do not mix both without a clear reason.

What makes a paginated response easy for clients to use?

Return one steady shape every time. items should stay an array, empty results should still look like items: [], and pagination metadata should not disappear just because there is no data.

What should a good API error response include?

Use one error body shape across 400, 401, 404, and 500 responses. A stable code, a readable message, and clear fields like retryable or form details let clients parse errors once and reuse that logic everywhere.

How do we roll out a shared API contract without a big rewrite?

Start with one feature that already causes repeat client work. Write the contract first, ship it behind a version flag or adapter if needed, and fix what you learn before you spread the pattern wider.

What mistakes break shared API contracts most often?

Teams usually break contracts by renaming enums to match UI text, mixing pagination styles, changing null rules quietly, or stuffing every failure into one generic message. Those changes look small on the server and create cleanup work in every client.

What should we check before release?

Before shipping, test real payloads for normal data, empty data, auth failure, and server failure on both web and mobile. If either client needs endpoint-specific parsing rules, tighten the contract before release.