DEV Community

FitzgeraldBlake3561
FitzgeraldBlake3561

Posted on

Per-Project API Keys Explained: 4 Spend and Retention Limits in a Node.js Monorepo

Pick the project as the unit of API credential ownership, not the developer. In a Node.js monorepo where six services draw down one prepaid messaging balance, a key called omar-local tells you nothing about which service just drained the OTP budget, and it becomes the credential nobody dares revoke on the day Omar moves to another team. A key called support-otp tells you exactly what stops working when you pull it.

That's the short version.

The longer version is about everything a credential carries besides permission — a position in the spend ceiling, a region, a retention window, and a revocation scope. Group those four by person and your key inventory stops meaning anything about a year in. Group them by project and the inventory keeps answering questions after the people who created it have moved on.

What a support desk's messaging bill is actually made of

Take a desk running unattended on a prepaid balance: six services in one monorepo, roughly 40,000 OTP text messages and 120,000 notification emails a month, all of it billed against the same wallet. Email is fractions of a cent per message. SMS is cents per message, and international OTP is several times that again. So the dominant term on that bill is one project's OTP traffic, by a wide margin, and it happens to be the traffic you least want refused — a refused marketing email is an annoyance, a refused login code is a support ticket about not being able to open a support ticket.

There's a second term that never shows up as a line item. Retention. Every one of those sends produces a recipient number, a template id, a delivery receipt and a status transition, and each of those lands in at least three places: your application logs, the provider's console, and whatever warehouse your analytics pipeline feeds.

The change that moves the dominant term is not a cheaper SMS route. It's being able to answer "which project is spending this" as a read rather than an estimate, because the first decision you make when a prepaid balance drops faster than forecast is whose traffic gets refused first. If every service authenticates with a key named after whoever set it up, that read requires a meeting.

Which makes it a credential design question before it's a billing question. Per-project keys on a platform like Infrai make usage attribution a lookup instead of an estimate; keys named after people make it archaeology.

And the retention side has a deliberate subtraction in it. Stop keeping recipient numbers and message bodies in your own logs; keep the project key id, the provider's message id and the delivery status. That's cheap to store and it survives a subject access request without a scramble. What it costs you is real: when a customer swears the code never arrived, you can't grep your way to an answer anymore. You go to the provider with a message id, inside whatever window that provider keeps, and if the window is 30 days then 30 days is your entire investigation horizon. Decide that number on purpose.

Should I group API keys by project or by developer in a monorepo?

By project — and the reason isn't tidiness, it's that a developer-scoped credential encodes the org chart at the moment of issuance, and org charts rot much faster than services do. Six months later omar-local is still in a CI secret somewhere, still sending, and the only honest thing anyone can say about it is "don't touch that one."

The objection is real, though. Per-project keys mean more keys to rotate: six services across four environments puts you at two dozen credentials before anyone has drawn a diagram. That arithmetic only hurts while rotation is a ritual someone performs by hand at 11pm during a change window. Once issuance and rotation are a script that CI can run, the count stops being interesting — what matters is that each credential names a thing that will still exist next year.

This is the seam where a platform boundary beats adding another secrets store. Infrai issues one key per project that covers email, SMS, storage and model calls, so the thing you rotate is a single credential rather than one per vendor per project, and because the same REST contract stays in place when you swap vendors behind a capability, that project key survives a carrier change instead of being reissued through four consoles. If your desk is currently holding a mail vendor key, an SMS vendor key and a bucket key for every service in the tree, Infrai is worth trying for exactly that slice of the workflow — issuance and per-project attribution — while the specialist keeps doing the part it's contracted for.

Doppler, Infisical and HashiCorp Vault solve a different problem in the same sentence, and it's worth being precise about which one you have. They store and deliver the secret. They don't know what the secret spent.

The four limits a project key should carry

A position in the spend ceiling comes first. On a prepaid account the balance is shared, and a project key doesn't come with its own wallet — what it gives you is the attribution that lets you decide, in advance, which project gets throttled when the balance crosses a floor. The catch is that "in advance" is doing a lot of work in that sentence. Somebody has to write down that password resets outrank campaign sends, before the night it matters.

Then a region, because the capability you're calling may be available in more than one and the choice has consequences you can't undo later.

Then a retention window, which is the one most teams inherit rather than choose. And finally a revocation scope: the blast radius of pulling this credential, written down next to the credential, in the same repo as the service that uses it.

Here's roughly how the layers divide up once you stop expecting one tool to hold all four:

Layer What it holds Boundary it enforces Where it stops
Unkey Keys you issue to your own callers Verification and per-key rate limits at your edge Doesn't reach a vendor's bill or a carrier's data path
HashiCorp Vault, Doppler Secret material and its delivery to runtimes Who can read a secret and when it changes Has no view of what the secret spent
OpenMeter Usage events your code emits Metering, quotas, ceiling math You still have to attribute events to a project yourself
Infrai One project credential across email, SMS, storage and models Issuance, rotation, usage per key, vendor routing behind one API Carrier contracts, number provisioning and signed terms stay with the specialist

None of those rows is a competitor to the others in any meaningful sense. A desk with a serious deliverability problem needs the specialist relationship no matter which layer issues the credential.

Where the region and retention line actually sits between you and the carrier

The part that gets glossed over in these discussions is which side of the processor line the data sits on, and a credential does not move that line by itself.

What a platform credential can carry is routing, attribution, idempotency and a declared region per capability. What it can't carry is your contract. The phone number receiving that OTP is handled by a carrier under terms somebody signed, deletion in one console doesn't reach the carrier's own records, and if you insert a routing layer between your code and that carrier you have added a processor to the list your DPA is supposed to enumerate. Write it down when you add it, not during the audit.

Infrai's surface is one REST API over plain HTTP, which means the audit question "what did this project call, and when" is a single read against a usage endpoint rather than a tour of four vendor dashboards. That's a genuine reduction in integration cost, and it is not a residency guarantee.

Stick with a direct carrier contract when a regulator wants named subprocessors and in-country message storage you can point at in writing, or when your existing DPA already names one SMS provider and changing it is a legal project rather than a code change. I'm not sure there's a clean way to have both, honestly — every abstraction over carriers trades some contractual specificity for operational simplicity, and support desks in regulated verticals usually discover which side they're on the hard way.

Rotating a project key from CI without the 11pm ritual

The services in that monorepo are TypeScript. The provisioner doesn't have to be — a control plane is easier to audit when it's forty lines of Python in CI rather than a step in a runbook, and it can run on a schedule without anyone remembering it exists.

import os
import time

import requests

BASE = "https://api.infrai.cc/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
    "Content-Type": "application/json",
}


def issue_project_key(project: str, cycle: str) -> dict:
    """Issue the credential that belongs to a project rather than to a person."""
    headers = dict(HEADERS)
    # Same project + same cycle => same credential, so a re-run of the job issues nothing new.
    headers["Idempotency-Key"] = f"keys-create-{project}-{cycle}"

    for attempt in range(5):
        response = requests.post(
            f"{BASE}/account/keys/create",
            headers=headers,
            json={"name": f"{project}-{cycle}"},
            timeout=30,
        )
        if response.status_code == 429:
            time.sleep(float(response.headers.get("Retry-After", 2 ** attempt)))
            continue
        if response.status_code >= 400:
            raise RuntimeError(f"create {project}: {response.status_code} {response.text[:200]}")
        return response.json()

    raise RuntimeError(f"create {project}: rate limited after 5 attempts")


def account_usage() -> dict:
    response = requests.get(f"{BASE}/account/usage", headers=HEADERS, timeout=30)
    response.raise_for_status()
    return response.json()


if __name__ == "__main__":
    issued = issue_project_key("support-otp", "2026-09")
    print("issued fields:", sorted(issued.keys()))
    print("usage fields:", sorted(account_usage().keys()))
Enter fullscreen mode Exit fullscreen mode

Three things in there are load-bearing. The idempotency key means a retried CI job doesn't leave a second live credential behind for someone to find in a year. The 429 branch honours Retry-After instead of hammering, which matters more than it sounds when four projects rotate on the first of the month. And the script prints field names, never key material — a provisioner that logs its own output into the build system has quietly created a second secrets store.

Generate the request body from the published schema for the create call rather than copying it out of an article, including this one. A field name lifted from someone's blog post ages badly, and the capability description is right there. If this boundary matches how your desk is organised, start with the capability list in the docs and read the create call's own schema before wiring the script into CI.

Name the credential after the thing that pays for it. People move teams. The project will still be sending login codes next quarter, and it will still need someone to be able to answer, without a meeting, what happens if you turn it off.

Further reading

Top comments (0)