APIs are used by programs which can’t adapt to change. So, whenever you change an API, the change should be backwards compatible so that the new API version can be used by existing integrations. As you make changes, you constantly need to keep track of this distinction:
Backwards-compatible changes
Changes you can roll out to existing integrations without causing problems for them. For example, adding new endpoints or accepting new parameters for existing endpoints. Breaking changes
These are changes that might break existing integrations so you can’t simply roll them out. To roll them out, you need to either ask all your existing integrations to migrate or you need to create a new version of the API so that new users get the change and old users don’t. For example, if you remove a property from an endpoints response, integrations that use that property will start throwing exceptions. Here is a list of the different breaking changes one can make to an API and some advice to deal with it. It is mostly based on what I learned working with the Stripe API, so the examples are very Stripe-centric, and applies to JSON REST APIs. If you have complementary examples from other APIs, please email them to me. Backwards-compatible changes
Adding new API resources. Adding new optional request parameters to existing API methods. Adding new properties to existing API responses. Changing the order of properties in existing API responses. Changing the length or format of opaque strings, such as object IDs, error messages, and other human-readable strings. This includes adding or removing fixed prefixes (such as ch_ on charge IDs). You can safely assume object IDs we generate will never exceed 255 characters, but you should be able to handle IDs of up to that length. Your webhook listener should gracefully handle unfamiliar event types. Be careful if your proposed change is not in this list, there is probably a good reason for it.
This list makes a set of assumptions about how clients integrate with the API. For example, “Adding new properties to existing API responses” could be either backwards compatible or a breaking-change depending on how the client is integrated.
For every one of those backwards compatible changes, there is some way to integrate that would make them breaking.
Some changes are backwards-compatible for certain ecosystems but not others. For example, it is possible for “Make a required parameter optional” be backwards compatible:
Languages that don't check types at runtime (ex: JS, Python, Ruby, PHP) can simply accept null or nil for the optional parameter. As long as the SDK doesn't have runtime asserts to check for the presence of that parameter, making the parameter optional is something that you can roll out overnight and the existing SDKs can accommodate. But for staticall-typed languages (like Java, Go, .NET, Haskell, or OCaml) you have to be more careful. SDKs in those languages usually check that all the right values are passed or use Maybe or Optional for optional parameters. So, making a required parameter optional would change the type signature of the SDK (ex: String → Optional<String>), triggering compiler errors for anybody that upgrades the SDK. To avoid this problem, Stripe uses the in its typed SDKs (Java, Go, .NET). To read how Stripe rolls out backwards-compatible changes without having to maintain separate code paths for each version, read this . Breaking changes
A good way to learn from others’ mistakes is to read . Stripe only upgrades the API version when it makes a breaking change somewhere. All backwards-compatible changes are immediately deployed to existing users so they don’t need their own version. The rest of the sections have examples of breaking changes that Stripe had to make like this:
: Updates the list format. New list objects have a data property that represents an array of objects (by default, 10) and a count property that represents the total count.
From that change, we can learn that list endpoints shouldn’t return a list at the top level. They should be wrapped by a map that can hold other data:
// This shouldn't be the top-level response
[{id: "ex_123"}]
// Do this instead:
{
data: [{id: "ex_123"}]
// if you ever need to, you can put data here:
metadata: "This turned out to be important"
}
When naming, be painfully concrete
The first Stripe APIs were all about payments and many of them included balance as a parameter. This was always some payment balance. But over time, as Stripe offered loans, bank accounts, and even cards, balance became a more and more ambiguous word. Are you talking about the loan’s balance or the bank account’s balance?
Users that come for the newer features like loans will interpret the original balance as loan balances. So, name them payment_balance from the get-go.
All of this applies to name, account, date, and other similarly vague words.
Beware of type and status
What is the type of a payment? While the team is trying to distinguish between subscription payments and one-time payments, they may be tempted to add type: subscriptions | one_time. But years later, a new type might emerge: was this payment made online or in-person?
type implies a form of categorizing, a complete ontology. status does the same but for state machines. But different perspectives require different ontologies and type and status make that first ontology privileged over future ones. What “aspect” of the object are you categorizing?
Instead of status, can it be fulfillment_status? Instead of type, or can it be timing_type? In my opinion, ugly is better than ambiguous.
: The amount field field in the tiers configuration for plans was renamed to unit_amount.
: Renames the type property on the Card object to brand.
: Replaces the account property on the Transfer object with bank_account. The bank_account property is only included when the transfer is made to a bank account.
: Replaces the user and user_email properties on the Application Fee object with an expandable account property.
: The date property has been renamed to created.
Consider how the API will evolve: hierarchy and related entities
Let’s say you are returning a balance that can grow stale. And just in case you add balance_as_of: "2023-01-01T00:00:00". Over the years, you keep adding balance_xyz fields, one at a time. 5 years later, half of the fields at the top level are balance related and it is hard for developers to find the fields the resource is nominally about. It would’ve been better if balance was a sub-resource, and the fields were balance.as_of, balance.amount, etc.
: Replaces the bank_accounts property on the Account object with external_accounts. Replaces the bank_account value in the fields_needed property with external_account.
: Renames the name property on the Bank Account object to account_holder_name.
Enums over booleans
Input parameters
Even if it feels very black-or-white, use an enum. For example, is_test: false is better as environment: test | live. If later you have staging or even a custom environment, you can still use that enum. Having is_test be a boolean invariable corners you later.
For input parameters, it is backwards compatible to accept a new case in the enum. But it is not backwards compatible to change the field from bool to enum. So get ahead of the problem and use an enum from starters.
: Replaces the disabled, validated, and verified properties on the Bank Account object with a status enum property.
: Replaces the managed Boolean property on Account objects with type, whose possible values are: standard, express, and custom. A type value is required when creating accounts. The standard type replaces managed: false, and the custom type replaces managed: true.
Return fields
Most of the previous section applies to return data as well. It will be easier for you to add an enum case later than to have to reconsider a boolean field. Is that backwards compatible? This depends on the integration pattern.
Many programming languages check for enum’s exhaustively: type Data = {
environment: 'test' | 'live' // Can you add 'staging' here?
}
switch (data.environment) {
case “test”: {
...
}
case “live”: {
...
}
// what goes here?
}
If the last clause is default and the code does something reasonable for unforeseen cases, then adding cases like staging to an enum is backwards compatible. If not, new enums will break the integration in one of two ways:
When the new enum is returned to that integration, the code will do something completely unexpected. To make matters worse, JavaScript will throw no exceptions in this case. Even if the new enum case was never returned to this particular integration, when the developer updates the SDK bindings with the new types, their compiler will check for exhaustiveness and throw a compile time error. This is vastly better than (1) but still annoying to force the developer to check for something that they don’t care about. At Stripe there is a rule that it’s only backwards compatible to return a new enum value if:
the user opts into it with your integration, like a new payment method type the enum fields is clearly not static like a list of banks or currencies Keep this in mind when writing documentation and explaining to users how to integrate. If you don’t insist on default clauses, you won’t be able to easily extend enums.
: Replaces the disabled, validated, and verified properties on the Bank Account object with a status enum property.
: Replaces the managed Boolean property on Account objects with type, whose possible values are: standard, express, and custom. A type value is required when creating accounts. The standard type replaces managed: false, and the custom type replaces managed: true.
Return as little data as possible
Stripe originally added count on the list endpoint because Mongo returned it from the list queries. It turns out that Mongo struggles to produce that count as collections grow, and it creates all sorts of performance problems. It is not clear that Stripe users need count in the first place. For that and other reasons, list endpoints are some of the worst endpoints to maintain.
Once count is sent to the user, Stripe doesn’t really know which users depends on it. It might be the case that very few users depend on count but Stripe has to assume that it is all of them. This is not the case for input parameters where it is easy to track what is being sent to Stripe. This example shows one reason you avoid adding data to your responses unless absolutely needed. But when do you add it?
When important use-cases can’t be completed without it. When enough users ask for it, first consider the cost, and then decide it is worth it. This sounds obvious but this whole section is about drilling into you that there are costs of returning data to users. : Removes the count property from list responses.
: Removes the other_transfers, summary, and transactions properties from automatic transfer responses in favor of the balance history endpoint (/v1/balance/history). These properties were very expensive to calculate on every transfer response, so they were moved to Balance Transactions where the user had to specifically ask for them.
Input data size and length
You will likely want to store many of the strings and arrays that users send you. Make sure to validate those strings and arrays with a max length and to advertise that max length. Otherwise, users will send you really long strings and very long arrays and leave you with a long database bill.