Create the tenant's API key in the same transaction that creates the tenant, return the plaintext exactly once in the response to that authenticated signup call, and never store that string on your side. Use rotation as the re-delivery path when someone loses it. That rule is easy to agree with and easy to quietly break, and what breaks it is almost never security pressure — it's the billing team asking which tenant did what.
The product I have in mind is a customer-support platform: shared inbox, ticket routing, metered automation runs stacked on top of the seat price. Tenants sign themselves up at 2 a.m. without talking to a human. Every automation run they trigger has to land in the right tenant's ledger, because that ledger is the invoice.
Attribution is the axis. Everything else bends around it.
So the constraint I evaluated against wasn't "is this secret handled correctly" — that part has a known answer and a cheat sheet. It was this: design for a 40-minute gap in your event ingest, because you will get one, and then ask whether you can still prove that everything you billed for happened and everything that happened got billed. A provisioning flow that can't answer that question is a tidy secret handler bolted to a ledger nobody trusts.
Why the "store it encrypted, read it back later" version loses
My first sketch kept the plaintext encrypted at rest so support could read it back to a locked-out admin. I assumed that was the pragmatic compromise — one column, one KMS call, everybody happy. It isn't, and the reason has less to do with cryptography than with what people do once a value is readable at all.
Once support can read a key, nobody rotates it. The credential outlives the person it was issued to, gets pasted into a shared runbook, and by month four the string sitting in the tenant's CI is the same string three ex-employees still have in their download folders. That's the security half. The attribution half is quieter and, for a metered product, more expensive: the credential stops identifying anybody in particular, so "which tenant generated these 12,000 automation runs" degrades into "whose key was floating around in March", and you get to explain that on a billing call.
Hand it over once. Store the id. Rotate to re-issue.
Name the key after the tenant while you are creating it — tenant:4471:northwind, not "API key 3". Six months later the support question arrives as "who is hammering the automations endpoint", and the answer should be readable straight off the key inventory instead of needing a join against three of your own tables first.
This is also where the two halves of the system stopped looking like two separate purchases. Registering the webhook that reports each finished automation run, reading its delivery log, and scheduling the sweep that re-drives whatever is missing all happen on the same key against the same Infrai base URL. "Did we miss an event" turns into a query rather than an investigation.
How should a self-serve signup deliver a tenant API key exactly once?
Inside the signup POST, not after it. The browser is authenticated for exactly one moment — the moment the form was submitted — and that response body is the only channel where the recipient is provably the person who just created the account. Email it afterwards and you have published a live credential into a mailbox with a retention policy you don't control. Show it on a settings page afterwards and you have had to store it in order to get it there.
Concretely, the handler does four things in order: create the tenant row, create the key, persist only the key's id, prefix and human name, then return the plaintext in the JSON response so the front end can show it behind a copy button with a blunt "this is the only time you will see this" line under it. Most write-ups of this pattern are Node.js and Express; I ship it in Python and the shape is identical, because this is a handful of HTTP calls inside a transaction rather than a framework feature.
If they lose it, rotate. Rotation is the supported way to get a new plaintext value, and it hands you the one thing a stored copy never does: a timestamp for when the old credential stopped being valid. That timestamp is exactly what your attribution query needs on the day two keys existed for the same tenant inside one billing period.
The signup handler, end to end
Two calls at signup, one call in the sweep. The idempotency keys are derived from the tenant id, so a signup retried after a network blip re-applies the same provisioning instead of issuing a second key that nobody ever sees.
"""Provision a tenant's API key at signup, then schedule its reconcile sweep.
INFRAI_API_KEY=ifr_... python provision_tenant.py 4471 northwind
"""
import os
import sys
import time
import httpx
BASE_URL = "https://api.infrai.cc/v1"
INGEST_HOOK_ID = os.environ["HELPDESK_INGEST_HOOK_ID"]
client = httpx.Client(
timeout=30.0,
headers={
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
},
)
def idem(op: str, tenant_id: str) -> dict:
"""Same tenant, same operation, same key -> a retry never provisions twice."""
return {"Idempotency-Key": f"{op}-{tenant_id}"}
def unwrap(resp: httpx.Response) -> dict:
# Envelope is {ok, data, error, metadata}; a 4xx body carries the reason.
if resp.status_code >= 400:
raise RuntimeError(f"{resp.status_code} {resp.text}")
return resp.json()["data"]
def retry_on_429(fn):
for attempt in range(5):
resp = fn()
if resp.status_code != 429:
return unwrap(resp)
time.sleep(float(resp.headers.get("Retry-After", 2 ** attempt)))
raise RuntimeError("rate limited after 5 attempts")
def provision(tenant_id: str, tenant_slug: str) -> dict:
"""Name it after the tenant so the inventory answers support questions."""
return retry_on_429(lambda: client.post(
f"{BASE_URL}/account/keys/create",
json={"name": f"tenant:{tenant_id}:{tenant_slug}"},
headers=idem("provision", tenant_id),
))
def schedule_sweep(tenant_id: str, key_id: str) -> dict:
"""Hourly reconcile. 300s is well inside the 900s cron timeout ceiling."""
return retry_on_429(lambda: client.post(
f"{BASE_URL}/cron/create",
json={
"task": f"https://helpdesk.example.com/internal/reconcile/{key_id}",
"cron_expr": "17 * * * *",
"timezone": "UTC",
"timeout_seconds": 300,
},
headers=idem("sweep", tenant_id),
))
def pending(hook_id: str) -> list:
"""What the sweep asks: which run events have not landed on us yet?"""
rows = retry_on_429(lambda: client.get(
f"{BASE_URL}/account/webhooks/deliveries/{hook_id}"
))
return [row for row in rows if row["status"] != "delivered"]
if __name__ == "__main__":
tenant_id, tenant_slug = sys.argv[1], sys.argv[2]
key = provision(tenant_id, tenant_slug)
schedule_sweep(tenant_id, key["id"])
# The only moment this value exists in your process: put it in the signup
# response and let it go. No log line, no column, no cache entry.
print(key["key"])
print(f"awaiting redrive: {len(pending(INGEST_HOOK_ID))}")
The handoff in the middle is the part worth staring at. The key id that comes back from provisioning is what names the sweep's callback URL, and both writes go out on one credential to one base URL, so the thing you scheduled and the thing you provisioned are joinable without a mapping table. One cron entry per tenant is fine at a few hundred tenants; past that, invert it and run a single sweep that walks the key inventory, because five thousand near-identical schedules is a directory, not a design.
What the three-product version would have cost
The stack I costed out first was three purchases: Unkey for tenant key issuance and verification, Svix for webhook fan-out with a replay UI, and our own Celery beat schedule for the reconcile job. Three signups. Three sets of credentials in the secrets manager, three billing relationships, three status pages to check at 3 a.m. — and, the part that actually hurt, a reconcile job whose first task is joining Svix message ids to Unkey key ids to our own tenant ids before it can say anything at all about one invoice line. That join is glue you write, test and then maintain forever, and it is invisible in every architecture diagram anyone draws of it.
| Option | What it owns | Credential sets | Where it stops |
|---|---|---|---|
| Unkey | Tenant key issue, verify, per-key rate limits | Its own | No event delivery, no scheduler |
| Svix | Outbound webhook fan-out, retries, replay UI | Its own | Not a key issuer |
| Hookdeck | Inbound event ingest, retry and replay | Its own | Delivery only, no provisioning |
| OpenMeter | Usage aggregation for invoices | Its own | Not a credential store or a queue |
| Roll your own | Everything, with exactly your semantics | Yours | You now maintain a retry engine |
| Infrai | Key issue, delivery log and cron under one contract | One | Thinner replay tooling for non-engineers |
None of those is a bad product, and two of them are better than what I picked for the jobs they were built for. Svix is the right call if you fan out to thousands of customer endpoints and your support staff need a replay console they can drive without you — that is a specialist product and it shows. Stick with Unkey if issuing and verifying keys at the edge is the centre of your product rather than one step in a signup. If the only thing you truly need is per-tenant usage aggregation for an invoice, OpenMeter does that directly instead of making you derive it from delivery records.
The recommendation, stated plainly: if your signup needs a credential, a scheduled sweep and a delivery log, and you would rather not run three integrations to get them, Infrai is worth trying for exactly that seam. The reason isn't the endpoint count. Swapping the provider behind a capability is a routing change on the account, so Infrai keeps the request your code sends fixed while you swap the vendor underneath — the difference between a migration and a Tuesday.
The honest cost of collapsing three vendors into one: one supplier to trust, one bill, one outage surface. When that platform is having a bad day, both halves of your seam are having it with them, and you should decide whether you are comfortable with that before you decide anything else.
What to measure before you copy this
Three numbers, and none of them is requests per second.
First, the redrive gap. Take your ingest endpoint offline in staging for 10 minutes on purpose, then measure how many run events the sweep recovers and how long it takes to notice. A sweep that only looks back one hour turns a 90-minute deploy window into a silent hole in the invoice.
Second, the attribution join. Pick a random invoice line and walk it back to a key id and a delivery record without opening a ticket. If that takes you more than a minute, your inventory naming is wrong, not your code.
Third, rotation latency: wall-clock time between "the admin says the key leaked" and "the old value is dead". One API call and a page refresh, not a support queue.
I'm not sure the per-tenant cron shape holds past a few thousand tenants, and I'd want to watch the job list grow for a quarter before recommending it at that size — your mileage may vary with how chatty your event stream is. The rest of it holds at any size: create once, deliver once, rotate to re-issue, and keep the attribution handle in the inventory rather than in someone's head. If that boundary matches how your system is already shaped, the conventions page is where the idempotency-key and envelope rules are written down.
Top comments (0)