SaaS Partner API: Rate Limits, API Keys, and Versioning for a Third-Party Developer Ecosystem
The moment a SaaS product opens a public API to third-party developers, the rules change. An internal API only has to survive your own frontend team's mistakes. A saas partner api has to survive strangers: integrators who retry too aggressively, forget to handle a 429, hardcode a response shape you plan to change next quarter, or ship a webhook handler that silently drops events. Most teams that build a partner API for the first time underestimate how much of the work is defensive design rather than feature work.
This post covers the five pieces that make a partner API operationally sound: API key management, rate limiting, versioning, webhooks, and observability. The examples use Symfony, but the underlying decisions apply regardless of framework.
API key management: generation, storage, and rotation
An API key is a credential, and it should be treated with the same care as a password. The three parts that most teams get wrong are generation, storage, and rotation.
Generate keys with a cryptographically secure random source, not uniqid() or a UUID alone. A common pattern is a public prefix for lookup (so you can identify which key made a request in logs without decrypting anything) followed by a long random secret: something like pk_live_ plus 32 random bytes encoded as base62. Symfony's random_bytes() combined with a base62 encoder covers this without extra dependencies.
Never store the raw key. Store a hash of it, the same way you would store a password, using password_hash() with a strong algorithm, or a fast keyed hash like HMAC-SHA256 if you need lookups at high request volume and can afford to trade some of bcrypt's brute-force resistance for speed (the key itself already carries enough entropy that this tradeoff is reasonable for API credentials, unlike for user passwords). When a request comes in, hash the presented key and look up the hash, not the plaintext.
Rotation needs to work without downtime for the partner. The practical approach is to let each partner account hold two active keys at once: a primary and a secondary. When it's time to rotate, generate a new secondary, let the partner update their integration, and only revoke the old key once you see traffic on the new one, or after a fixed grace period. A single-key model that dies the instant you regenerate it forces every rotation into a support ticket.
Scope keys to what they should be able to do. A key that can read order data does not need to also be able to issue refunds. Symfony's security voters map cleanly onto this: attach a set of scopes to each key at creation, and check them in a voter rather than scattering if ($key->hasScope()) checks through your controllers.
Rate limiting that is fair, not just strict
A partner API needs rate limiting for two different reasons: protecting your infrastructure from a partner's bug, and protecting well-behaved partners from each other. A single global rate limit does neither well.
Rate limit per API key, not per IP. Partners often call your API from shared infrastructure, load balancers, or serverless functions with rotating outbound IPs, so IP-based limits either miss abusive keys hiding behind a large IP pool or wrongly throttle several unrelated partners sharing one IP.
A sliding window counter is the right algorithm for most partner APIs. A fixed window (reset every hour on the hour) allows a partner to burst double their limit by hitting the boundary, sending a full quota's worth of requests in the last second of one window and another full quota in the first second of the next. A sliding window, implemented with a sorted set in Redis keyed by API key, tracks the actual request timestamps in the trailing period and avoids that edge case. Symfony ships a Rate Limiter component with a sliding window policy built in, backed by Redis or another supported cache adapter, so this doesn't need a custom implementation.
Allow a burst on top of the sustained rate. A partner syncing a backlog of records after a temporary outage on their side needs to be able to send a short spike of requests without hitting a wall, as long as their average stays within the agreed limit. A token bucket layered on top of the sliding window handles this: a fixed number of tokens refill at a steady rate, and requests draw from the bucket, so short bursts are absorbed while sustained overuse is not.
When you do throttle a request, say so clearly. Return a 429 status code with a Retry-After header telling the caller how many seconds to wait, and include the remaining quota in X-RateLimit-Remaining on every response, not just the throttled ones, so well-written integrations can back off before they hit the limit rather than after.
Versioning: pick one strategy and be consistent
There are three common ways to version a public API, and the choice matters less than picking one and applying it consistently everywhere.
URL versioning, putting the version in the path like /v2/orders, is the most visible to partners and the easiest to route in a reverse proxy or in Symfony's routing configuration, since you can point an entire prefix at a different controller namespace. Its downside is that it encourages entire duplicate route trees even for endpoints that haven't actually changed.
Header versioning, sending something like Api-Version: 2026-06-01, keeps URLs stable and lets you version resources independently rather than the whole API at once, but it's easy for partners to forget to set the header, and harder to test manually in a browser or with a quick curl command during integration.
Content negotiation, versioning through the Accept header's media type, is the most correct in a strict REST sense but the least common in practice, and most partner developers will not expect it.
For most SaaS partner APIs, URL versioning is the pragmatic choice specifically because it's the easiest for a third-party developer to understand without reading your documentation closely, and that matters more for adoption than architectural purity. Whichever you choose, commit to supporting the previous version for a stated deprecation window, publish the deprecation date in the response headers of the old version, and never silently change a response shape within a version. If a field's meaning needs to change, that's a new version, not a patch.
Webhooks: reliability is the whole feature
A webhook system that occasionally drops events is worse than no webhooks at all, because partners build business logic on the assumption that every event arrives.
Every webhook delivery needs a retry policy with exponential backoff, since a partner's endpoint being briefly unavailable is normal and shouldn't cost them the event permanently. A reasonable default is a handful of retries over a few hours, then marking the delivery failed and surfacing it in a dashboard the partner can check. Symfony Messenger's retry strategy, configured per transport, handles the backoff scheduling without custom cron jobs.
Sign every payload with an HMAC signature, computed with a per-partner secret and sent as a header alongside the request. This lets the partner verify that a request claiming to be your webhook actually came from you and wasn't replayed or forged, and it's a two-line implementation on both sides: you sign with hash_hmac('sha256', $payload, $secret), they verify the same way.
Make deliveries idempotent from the partner's side by including a unique event ID in every payload, and encourage partners to deduplicate on that ID, because retries will occasionally result in the same event being delivered twice even when everything on your end worked correctly.
Documentation and observability
Documentation is what turns a technically correct API into one that integrators can actually use without opening a support ticket. The details that matter most are the ones generic API reference generators tend to skip: what a 429 response body looks like, what happens to a webhook if the partner's endpoint returns a 500, and a worked example of the full authentication flow with a real (test-mode) key. Wolf-Tech has found that partner integrations go noticeably faster when the docs include copyable request examples in at least two languages, since not every partner developer will be working in PHP.
On your side, observability needs to be broken down per partner, not just in aggregate. Track request volume, latency, and error rate segmented by API key, because a single misbehaving integration averaged into your overall API metrics can hide inside a healthy-looking dashboard while quietly failing for one partner. A latency spike affecting one partner's key is a very different incident from a platform-wide slowdown, and your monitoring should be able to tell them apart at a glance.
Getting the foundation right the first time
A partner API is a long-term commitment. Once external developers build against it, every design decision, from how keys are scoped to which versioning strategy you picked, becomes expensive to change. Wolf-Tech builds and reviews partner APIs for SaaS teams as part of custom software development, and audits existing partner integrations under code quality consulting when an API built early in a product's life needs to catch up to the scale it's now serving.
If your team is scoping a partner API or inheriting one that's starting to show its age, reach out at hello@wolf-tech.io or find more of our engineering notes at wolf-tech.io.

