Adding an API to a small SaaS is mostly not a REST question. The routes are the easy part. The decisions that bite are about what the API is allowed to see, who it answers to, and how it fails.
I shipped a read API last week for a product that already had a public widget endpoint and a billing plan with an api_access flag that had been granting precisely nothing since launch. Here is what actually needed deciding.
Read only, on purpose
The first version exposes GET and nothing else. Not because writes are hard, but because every verb you expose is a promise you maintain forever, and the dangerous verbs are the ones with side effects your users see.
For a changelog tool the risky verb is publish. A write API means somebody's broken script can push a half written entry to their customers at 3am, and no amount of "well, they called it" makes that a good day. Read gives integrators most of what they actually ask for (pull entries into a docs site, mirror them into Slack, back them up) with none of that.
Ship the surface you can support. Adding POST later is a feature announcement. Removing it is a breaking change.
The API and the UI must agree on what "published" means
This is the one I would put at the top if I could only keep one.
The product supports scheduled entries: an entry can be marked published with a date in the future, and the widget deliberately hides it until that date arrives. That behaviour lives in a query the widget endpoint uses.
The API has to use the exact same query. If it filters on status = published alone, it happily hands out entries the customer scheduled for next Tuesday. Nobody would call that a security hole in a bug report, but it is an embargo leak: the product promised "this goes live Tuesday" and then served it on Friday to anyone with a token.
So the rule is: any new read surface reuses the existing visibility scope rather than reimplementing the filter.
$query = match ($status) {
// Published means published AND past dated, exactly as the widget sees it.
'published' => $project->publishedEntries(),
'draft' => $project->entries()->where('status', 'draft'),
default => $project->entries(),
};
The general form: whenever two code paths answer "what is visible", one of them will drift. Make them the same function. If you cannot, write the test that asserts they agree.
401, 403 and 404 are three different sentences
Most APIs blur these and it makes them miserable to integrate with. The distinctions I settled on:
401 means the token is missing or unknown. Nothing else.
403 means the token is real but the thing you are asking for is not available to it. That covers two cases: the plan does not include API access, and the project exists but belongs to a different account.
404 means the resource genuinely does not exist for anybody.
The second 403 case deserves an argument, because the security reflex is to return 404 for another user's resource so the API does not confirm the ID exists. That reflex is right for an unauthenticated or public endpoint. Here the caller has already proven they hold a valid token for a real account, so the enumeration win is small, and the cost is a developer staring at a 404 for an ID they can see in their dashboard. I took the clearer error.
The error bodies say which it is, too. "The API is a Pro feature. Upgrade to use it." is a support ticket that never gets opened.
Gate on plan data, not on code
The plan check is one line reading a column:
if (! $token->user?->activePlan()?->api_access) {
abort(403, 'The API is a Pro feature. Upgrade to use it.');
}
api_access is a boolean on the plan row, alongside the other entitlement flags. No plan names in controllers, no if ($user->plan === 'pro') scattered across the codebase. When a plan changes, or a grandfathered account needs an exception, it is a database update and not a deploy.
Two smaller things that fall out of this: the check happens server side on every request (never trust a flag the frontend read), and a downgrade takes effect on the next call rather than whenever a cache decides.
Cap the page size, not just the default
Pagination defaults are easy. The cap is the part people skip:
private const MAX_PER_PAGE = 100;
$perPage = min((int) $request->query('per_page', 25), self::MAX_PER_PAGE);
Without the min, ?per_page=100000 is a request that reads an entire account into memory and serializes it, and the first person to try it will be a well meaning integrator writing a sync job, not an attacker. Validate the input, clamp the value, and put a rate limit on the route as well. They defend different things: validation bounds one request, the throttle bounds a loop.
Tokens: show once, revoke instantly
The token screen creates, lists and revokes. The plain value is displayed exactly once at creation and is not readable afterwards, because only a hash is stored.
The test worth writing is not "a valid token works". It is "a revoked token stops working on the very next request". That is the assertion that catches the day someone adds a cache in front of token lookup, and it is the entire security promise of a revoke button.
One limitation I will state plainly rather than let somebody discover: tokens are scoped to the account, not to a single project, so a leaked token reaches everything that account owns. That is acceptable for a first iteration with a small user base, and the fix is a nullable project column applied at lookup. Knowing where your own edges are is more useful than pretending you have none.
Tell integrators where data came from
Each entry in the response carries a source field, manual or github, plus the commit range it was drafted from when it came from a push. It costs two columns and it answers a question an integration will otherwise have to guess at.
Cheap metadata that preserves provenance tends to be worth more than it looks a year later.
None of this is exotic. It is just the set of things that are annoying to change once other people's scripts depend on them, which is why they are worth an hour before launch rather than a migration after.
I build Patchlog, a changelog widget for small SaaS, and this is the API it now ships on the Pro plan: read only over your own projects and entries, four endpoints, bearer authenticated. If you want the same shape, take the decisions rather than the product.
Top comments (0)