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:
- Their availability. Their downtime is your downtime unless you designed otherwise.
- Their pricing. Vendors reprice. Your margin is partly in their hands.
- Their decisions. Deprecated endpoints, changed limits, discontinued products.
Vendor selection
Before adopting anything you'll depend on:
| Check | Why |
|---|---|
| Pricing at 10× your current scale | The free tier is bait; know the real cost |
| Rate limits | Both steady-state and burst |
| Uptime history / status page | Do they publish incidents honestly? |
| API stability | Versioning policy, deprecation notice period |
| Data export | Can you leave with your data? |
| Support responsiveness | Test it before you need it |
| What it ships in your binary | SDKs drag dependencies and permissions |
| Company health | Funded? 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:
- Timeouts on everything. A hanging request is worse than a failed one.
- Retries with exponential backoff and jitter, for idempotent operations only.
- Circuit breakers for repeated failures; stop hammering a dead service.
- Graceful degradation. What does your product do when this vendor is down? "Nothing works" is a choice, and usually the wrong one.
⚠️ 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
- Know the limit before launch, and whether it's per second, per day, or per month. A daily limit fails differently from a monthly one, it fails on your best day.
- Queue rather than drop when you hit a ceiling.
- Cache aggressively. The cheapest API call is the one you don't make.
- Handle 429 properly, respect
Retry-After, don't retry immediately.
Webhooks: receiving
Inbound webhooks are how vendors tell you things happened. Non-negotiables:
| Rule | Why |
|---|---|
| Verify the signature or secret | Otherwise anyone can post events to you |
| Fail closed on missing config | A misconfigured deploy must reject, not accept |
| Be idempotent | They retry; duplicates must be no-ops |
| Return 2xx fast | Do slow work in a queue, not in the handler |
| 200 for permanently unprocessable | 4xx/5xx causes infinite retries |
| Log the raw payload | You will need it to debug |
API keys
- Publishable vs secret. Publishable keys are designed to be public provided your server-side rules are correct. Secret keys never touch a client, ever.
- Annotate the confusing ones. When you commit an identifier that looks like a secret but isn't, write a comment saying why (Chapter 16).
- Rotate on any suspicion. Keys in git history are compromised even after deletion.
- Separate keys per environment.
If you expose an API
- Version from day one (
/v1/). Retrofitting versioning is painful. - Cursor pagination, not offset, offset breaks when data changes underneath.
- Consistent errors with a machine-readable code and a human-readable message.
- Rate limit from the start.
- Document with examples, not just schemas. Working curl commands are what people actually use.
📐 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
| Need | Options | Typical cost |
|---|---|---|
| Payments | Stripe, Paddle, Lemon Squeezy | 2.9%+30¢ / 5% MoR |
| Mobile subscriptions | RevenueCat, Adapty | Free → 1% |
| Resend, Postmark, Brevo | $0-20/mo | |
| SMS | Twilio, MessageBird | Per message |
| Storage | Cloudflare R2, S3, UploadThing | Usage |
| Search | Algolia, Typesense, Meilisearch | $0-$$$ |
| Maps | Mapbox, Google Maps | Usage |
| AI | OpenAI, Anthropic, OpenRouter | Per token (Ch 23) |
| Background jobs | Inngest, Trigger.dev | Free tiers |
| Webhook testing | ngrok, Webhook.site, Hookdeck | $0-20/mo |
| API client | Postman, Bruno, Hoppscotch | $0 |
| Status monitoring | Better 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
- I know each vendor's pricing at 10× scale
- I know each rate limit and its period
- Each vendor is isolated behind my own interface
- Timeouts on every external call
- Retries only on idempotent operations, with backoff
- No silent fallback changes behaviour
- A degraded mode is designed for each critical vendor
- Webhooks verify, fail closed, are idempotent, return fast
- Webhooks return 200 for permanently unprocessable events
- Secret keys never reach a client; publishable ones annotated
- I audited what each SDK ships in my binary
- Integration failures are instrumented, not silently caught
- I have a written note on how to swap each critical vendor
📓 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
- Read the package source before engineering around a limitation. Thirty minutes beat a vendoring pipeline, and found a documented artifact that doesn't exist.
- Your dependencies set your platform floor. Audit their requirements on day one, not day 21.
- When forced to upgrade, take the whole ecosystem. Half-upgraded is the worst state.
- Audit what each SDK ships, not just what you call, trackers change your privacy obligations.
- Webhooks must fail closed. A missing secret rejecting everything is the correct behaviour.
- Return 200 for permanently unprocessable events, or you get infinite retries.
- Never let a fallback change behaviour around money, identity or permission.
- Isolate vendors behind your own interface. One hour now; one file to change later.
- Know the period of every rate limit, daily limits fail on your best day.
- Design the degraded mode. "Nothing works when they're down" is a choice.