All chapters Part IV · Building
Chapter 22

APIs and Third-Party Integrations

Every integration is a dependency on someone else's uptime, pricing, roadmap and judgement. That trade is almost always worth making, but it should be a decision you made, not one you drifted into.


The concept

Modern products are mostly integration. Auth, payments, email, storage, search, maps, AI, analytics, each is a solved problem you rent rather than build (Chapter 15). The engineering discipline isn't whether to integrate; it's integrating in a way that survives the vendor having a bad day.

Three things you inherit from every dependency:

  1. Their availability. Their downtime is your downtime unless you designed otherwise.
  2. Their pricing. Vendors reprice. Your margin is partly in their hands.
  3. Their decisions. Deprecated endpoints, changed limits, discontinued products.

Vendor selection

Before adopting anything you'll depend on:

CheckWhy
Pricing at 10× your current scaleThe free tier is bait; know the real cost
Rate limitsBoth steady-state and burst
Uptime history / status pageDo they publish incidents honestly?
API stabilityVersioning policy, deprecation notice period
Data exportCan you leave with your data?
Support responsivenessTest it before you need it
What it ships in your binarySDKs drag dependencies and permissions
Company healthFunded? Profitable? Acquisition risk?

Prefer boring, widely-used vendors for anything critical. A hot new API with great DX and eight months of runway is a risk you're taking with your product's uptime.

Integration patterns

Isolate every vendor behind your own interface. Not a heavyweight abstraction, a thin module that means the vendor's types don't leak through your codebase:

 your code → your interface → vendor SDK

This costs an hour and it means switching providers touches one file rather than forty. It also makes testing trivial: mock at your boundary, not inside your own logic.

Handle failure explicitly. Every network call has four outcomes: success, failure, timeout, and wrong. Design for all four:

⚠️ Never let a fallback silently substitute a different outcome. A fallback that logs is defensive engineering. A fallback that quietly changes what happens, grants access, charges a different amount, shows different content, is a bug that looks like resilience (Chapter 29).

Rate limits and quotas

Webhooks: receiving

Inbound webhooks are how vendors tell you things happened. Non-negotiables:

RuleWhy
Verify the signature or secretOtherwise anyone can post events to you
Fail closed on missing configA misconfigured deploy must reject, not accept
Be idempotentThey retry; duplicates must be no-ops
Return 2xx fastDo slow work in a queue, not in the handler
200 for permanently unprocessable4xx/5xx causes infinite retries
Log the raw payloadYou will need it to debug

API keys

If you expose an API


📐 Best practice

Read the vendor's source or actual behaviour before engineering around a limitation. Documented behaviour and real behaviour differ more often than you'd expect.

Isolate each vendor behind a thin interface of your own.

Timeouts on every external call.

Retry only idempotent operations, with backoff and jitter.

Cache aggressively.

Design the degraded mode, what works when this vendor is down?

Verify webhooks and fail closed.

Know the pricing at 10× scale, and the period of every rate limit.

Audit what each SDK adds to your binary, size, permissions, trackers.

Log integration failures as events you can see (Chapter 38), not as silent catches.


💀 Common mistakes

Vendor types leaking through your whole codebase. Switching becomes a rewrite.

No timeouts. One slow vendor hangs your product.

Retrying non-idempotent operations. Duplicate charges, duplicate emails.

Silent fallbacks that change behaviour. Resilience-shaped bugs.

Discovering the rate limit in production, on your best day.

Webhooks that don't fail closed. Anyone can grant themselves anything.

Webhooks that 4xx on unprocessable events. Infinite retry storms.

Slow work inside the webhook handler. Timeouts and duplicate deliveries.

Secret keys in client code. Extracted within hours.

Adopting an SDK without checking what it ships. Trackers, permissions and privacy-label obligations you didn't sign up for.

No plan for the vendor disappearing.


The professional workflow

 1. DECIDE: build or integrate? (Chapter 15 — integrate almost always)

 2. EVALUATE VENDORS
    pricing at 10× · rate limits AND their period · uptime history ·
    versioning policy · data export · what the SDK ships

 3. PROTOTYPE THE HARDEST CALL FIRST — before committing

 4. ISOLATE behind your own thin interface

 5. HANDLE FAILURE
    timeout · retry (idempotent only) · circuit break · degraded mode

 6. INBOUND WEBHOOKS
    verify · fail closed · idempotent · fast 2xx · 200 for
    permanently-unprocessable · log raw

 7. KEYS
    publishable vs secret · per environment · annotated · rotatable

 8. INSTRUMENT — success rate, latency, failure reasons

 9. DOCUMENT the integration: what it does, what breaks if it's down,
    how to swap it

Tools, websites & costs

NeedOptionsTypical cost
PaymentsStripe, Paddle, Lemon Squeezy2.9%+30¢ / 5% MoR
Mobile subscriptionsRevenueCat, AdaptyFree → 1%
EmailResend, Postmark, Brevo$0-20/mo
SMSTwilio, MessageBirdPer message
StorageCloudflare R2, S3, UploadThingUsage
SearchAlgolia, Typesense, Meilisearch$0-$$$
MapsMapbox, Google MapsUsage
AIOpenAI, Anthropic, OpenRouterPer token (Ch 23)
Background jobsInngest, Trigger.devFree tiers
Webhook testingngrok, Webhook.site, Hookdeck$0-20/mo
API clientPostman, Bruno, Hoppscotch$0
Status monitoringBetter Stack, Checkly$0-30/mo

Alternatives & trade-offs

Direct SDK vs REST. SDKs are faster to write and add dependency weight, version constraints, and sometimes trackers. REST over fetch is more code and fully under your control. For one or two endpoints, skip the SDK.

One vendor vs multi-vendor abstraction. Building for two providers upfront doubles the work for a switch that may never happen. Isolate behind an interface, commit to one, keep switching cheap.

Vendor lock-in vs speed. Deep integration (a BaaS's realtime, auth and storage) is dramatically faster and harder to leave. Shallow integration is portable and slower. Early on, speed usually wins, just keep your business logic in your code rather than in vendor-specific features.

Self-hosting open-source alternatives (Typesense, Meilisearch, PostHog, Supabase) removes vendor risk and adds ops burden. Right when you have the skills and a cost or compliance reason; wrong as a default.

Aggregators (OpenRouter for models, Stripe for payment methods) reduce switching cost and add a layer, a margin, and a dependency of their own.


Checklist


📓 Case Study: reading the source instead of engineering around it

Project: SOLIS, integrating payments, auth, backend and email into an app with no bundler, a constraint that made every SDK integration a question.

The pattern worth copying: read the package before engineering around it.

The project needed a payments SDK that shipped ESM-only, designed to be imported and bundled. With no bundler, the assumed solution was to vendor a UMD build, which is what had been done for the database client, adding a vendored file to keep in sync.

Thirty minutes of reading the package source instead:

The SDK's export is just a proxy over the native plugin registration, which the native wrapper attaches automatically to a global. The proxy only special-cases one method that this app doesn't use; everything needed passes straight through.

So use the global directly. No vendoring, no bundler. (The UMD file the package advertises isn't even shipped.)

Half an hour of reading eliminated a vendoring step, a sync step, and a stale-copy risk, and discovered that a documented artifact didn't exist, which would otherwise have been an hour of confusion.

📐 Documented behaviour and actual behaviour diverge more often than you'd expect. Before building a workaround, spend thirty minutes in the package.

💀 The dependency cascade. On day 21 the task was "add social sign-in", an afternoon's work:

"Add Apple sign-in"
 └─ need a social-login plugin
    └─ the maintained one requires framework v8  (project was on v6)
       └─ upgrade the framework  (project-wide)
          └─ every first-party plugin bumps
             └─ payments SDK 9 → 13  (two major versions)
                └─ minimum OS 13 → 15
                   └─ drops support for older devices

The alternative, a v6-compatible plugin still in release candidate, meant shipping unfinished code in an auth flow adjacent to payments. Worse.

Your dependencies set your platform floor, not the framework's documentation. A ten-minute audit on day one would have surfaced this; instead it arrived on day 21 with a working app to keep working.

It ended reasonably: landing on current libraries before writing payment code meant that work started on supported versions. When forced to upgrade, take the whole ecosystem, half-upgraded is the worst state to be in.

An SDK that arrived uninvited. The social-login plugin shipped a third-party tracking SDK by default. It was explicitly disabled in configuration to keep it out of the binary, because its mere presence would have forced a heavier privacy declaration, whether or not it was used. Audit what lands in your binary, not just what you call.

The webhook was done properly, and it's a good template:

const expected = env("WEBHOOK_SECRET");
const got = req.headers.get("Authorization");
if (!expected || got !== expected) return new Response("unauthorized", { status: 401 });

!expected is the important clause, a missing secret rejects everything. The alternative, treating unset config as "no auth required," means a misconfigured deploy accepts entitlement grants from anyone on the internet. Verified behaviour: 401 until the secret was set.

And it handled unprocessable events correctly, returning 200 for events carrying an anonymous vendor id that could never be mapped to a user, rather than an error that would trigger infinite retries. Distinguish "can't process this, retry" from "can't process this, ever."

⚠️ Deviation: a silent fallback changed behaviour.

The payment code contained this:

const pkg = packages.find(p => p.id === wanted) || packages[0];   // ⚠️

The || packages[0] reads as sensible defensiveness. What it actually does: if the plan the user selected isn't in the offering, charge them for whichever plan happens to be first. Choose annual, get billed monthly.

The fix removed the fallback entirely, with the reasoning kept in place:

Deliberately NO fallback: substituting whatever package is first charges the user for a plan they did not pick. Failing over to the free path is the only honest option.

|| defaults are fine for display values and catastrophic around money, identity, or permission.


Lessons

  1. Read the package source before engineering around a limitation. Thirty minutes beat a vendoring pipeline, and found a documented artifact that doesn't exist.
  2. Your dependencies set your platform floor. Audit their requirements on day one, not day 21.
  3. When forced to upgrade, take the whole ecosystem. Half-upgraded is the worst state.
  4. Audit what each SDK ships, not just what you call, trackers change your privacy obligations.
  5. Webhooks must fail closed. A missing secret rejecting everything is the correct behaviour.
  6. Return 200 for permanently unprocessable events, or you get infinite retries.
  7. Never let a fallback change behaviour around money, identity or permission.
  8. Isolate vendors behind your own interface. One hour now; one file to change later.
  9. Know the period of every rate limit, daily limits fail on your best day.
  10. Design the degraded mode. "Nothing works when they're down" is a choice.

Next: Chapter 23: AI Integration →

Useful? Share this chapter