How should a subscription tracker API be designed?
Four resources, API keys rather than OAuth for this use case, idempotent creates, and the pagination contract that makes automation reliable.
Answer
Four resources — subscriptions, categories, reminders, events — with API keys rather than OAuth, since the caller is almost always the user automating their own account. Support an idempotency key on creates, paginate with an opaque cursor rather than an offset, and emit webhooks, because polling is what integrations do when you do not.
Leutrim Miftaraj
Founder, SubTracker · Updated September 5, 2026
The short answer
For a personal-data product the integration surface is one user automating their own account, not a third-party app acting for many. That single fact removes OAuth, scopes and a consent screen from the design, and what remains is small enough to build properly.
Who is actually calling
Worth settling before any endpoint design, because it determines most of it.
For a subscription tracker the caller is overwhelmingly the user themselves, through Zapier, Make, a shell script or a personal automation. It is not a third-party application acting on behalf of many users.
That removes OAuth, scopes, a consent screen and a client registry — a substantial amount of work that would serve almost nobody.
API keys, generated in settings, scoped to one account, revocable. Show the key once, store a hash. If you later need third-party apps, add OAuth then; the resource design does not change.
Four resources
""` GET /v1/subscriptions?status=active&cursor=... POST /v1/subscriptions GET /v1/subscriptions/:id PATCH /v1/subscriptions/:id DELETE /v1/subscriptions/:id
GET /v1/categories GET /v1/reminders?subscription_id=... POST /v1/reminders GET /v1/events?since=... ""`
Resist verbs. "POST /v1/subscriptions/:id/cancel" is tempting and is a status change: "PATCH" with "{"status": "cancelled"}". One code path, one set of validations, one place where the transition rules live.
"/v1/events" is the read side of the webhook stream and is what makes an integration recoverable after downtime — without it, a consumer that missed a webhook has no way to catch up short of a full resync.
Idempotent creates
Automation retries. Zapier retries, Make retries, and a shell script in a loop retries. Without idempotency the user ends up with four copies of one subscription and no way to tell which is real.
""` POST /v1/subscriptions Idempotency-Key: 8f14e45f-ea4c-4b1e-9c1b-0f8b1c1e2a33 ""`
""`sql create table idempotency_keys ( key text primary key, user_id uuid not null, response jsonb not null, created_at timestamptz not null default now() ); ""`
Store the response, not just the key. A retry must return the same body as the original — returning 409 tells the caller something went wrong when nothing did, and most automation tools will surface that as a failure to the user.
Expire keys after 24 hours. Longer serves no purpose and the table grows.
Cursor pagination, not offset
""`json { "data": [ ... ], "next_cursor": "eyJpZCI6IjAxOTIuLi4ifQ", "has_more": true } ""`
Offset pagination is fine until rows are inserted or deleted between pages, at which point the consumer silently skips or repeats records. For a sync integration that is a data-integrity problem rather than a display glitch.
An opaque cursor encoding the last id, with a stable sort, is a few lines more and does not have the failure.
Document the page size and cap it. A consumer requesting 10,000 records will do so on every poll if you let them.
Emit webhooks, or accept polling
If you do not push, integrations poll — usually every five minutes, forever, mostly returning nothing. That is your infrastructure cost and it is entirely avoidable.
""`json { "id": "evt_01923...", "type": "subscription.created", "created_at": "2026-09-05T10:14:22Z", "data": { "subscription": { ... } } } ""`
Four event types cover it: "subscription.created", "subscription.updated", "subscription.deleted", "subscription.renewal_due".
Sign the payload with a shared secret and a timestamp, so consumers can verify origin and reject replays. Retry with backoff and give up after a bounded number of attempts, recording the failure where the user can see it. And provide the "/v1/events" read endpoint as the catch-up path, because a consumer that was down for an hour needs a way back to consistency that is not a full resync.
The symmetry is deliberate: everything the earlier page says about consuming webhooks reliably applies to the people consuming yours.
Errors that a machine can act on
""`json { "error": { "type": "validation_error", "message": "billing_every must be between 1 and 60", "field": "billing_every" } } ""`
A stable machine-readable "type", a human-readable "message", and a "field" where one applies. Correct status codes: 400 for validation, 401 for a bad key, 403 for a valid key without access, 404, 409 for a conflict, 429 with "Retry-After".
The single most useful of those is 429 with "Retry-After". Without it, a rate-limited automation retries immediately and stays rate-limited, and the user experiences it as the integration being broken.
Die ehrliche Bau-oder-Kauf-Linie
Das Schema ist der einfache Teil. Es ist auch der Teil, der sich nach Fortschritt anfühlt — weshalb das Projekt unmittelbar danach aufhört, Spass zu machen.
Was tatsächlich Zeit kostet, in der Reihenfolge, in der es zubeisst: geplante Erinnerungen mit Idempotenz und Zeitzonen, Wiederholungs-Arithmetik über unregelmässige Intervalle, Währungsbehandlung, und ein Import, aus dem heraus überhaupt etwas entsteht.
Nichts davon ist intellektuell schwierig. Es ist schwierig im Sinne von vier Wochenenden, und das vierte ist das, an dem das Projekt stirbt.
Wenn das Ziel Lernen ist: bau es. Der Code hier ist echt und funktioniert. Wenn das Ziel ist, kein Geld mehr an vergessene Abos zu verlieren, ist der Bau ein Umweg.
Was SubTracker damit macht
Zur Einordnung, weil die Entscheidungen oben aus einem laufenden System stammen und nicht aus einer Entwurfsübung.
SubTracker ist eine Next.js- und PostgreSQL-Anwendung (Supabase), die Abos manuell verfolgt — es gibt keine Bankverbindung, jede Zeile stammt aus einem Formular oder einem CSV-Import. Diese Einschränkung prägt das Schema: kein „erkannt"-Zustand, kein Konfidenzwert, keine Händler-Zuordnungstabelle. Jede Zeile ist eine Behauptung, die jemand aufgestellt hat.
Der kostenlose Plan verfolgt unbegrenzt viele Abos inklusive CSV-Import und Live-Kalender-Feed. Erinnerungen und Preis-Alarme sind Plus für $5.90/Monat; Family kostet $9.90/Monat und fügt einen geteilten Workspace für bis zu zehn Personen hinzu — an dem Punkt hören die Besitzverhältnisse im Schema auf, theoretisch zu sein.
Frequently asked questions
Should a personal-data API use OAuth or API keys?+
API keys, when the caller is the user automating their own account rather than a third-party app acting for many. That removes OAuth, scopes and a consent screen, and the resource design does not change if you add OAuth later.
How do you make API creates idempotent?+
Accept an Idempotency-Key header and store the response body against it, returning the same body on a retry. Returning 409 instead tells the caller something failed when nothing did, and most automation tools surface that as an error.
Why use cursor pagination instead of offset?+
Because rows inserted or deleted between pages cause offset pagination to silently skip or repeat records. In a sync integration that is a data-integrity problem rather than a display glitch.
Do I need webhooks if I have an API?+
If you do not push, integrations poll every few minutes indefinitely, mostly returning nothing — that is your cost. Sign the payloads, retry with backoff, and provide a read-side events endpoint so a consumer that missed deliveries can catch up.
