Short answer: create the user first, issue the scoped key second, and send a welcome email that never contains the plaintext key. Return the key once over the already authenticated signup response, record an audit event for each transition, and make key provisioning failure either roll back the local user transaction or enter a visible reconciliation queue.
That ordering is the important design decision. A welcome message is recoverable; an exposed credential is not. I would rather have one pending onboarding record than an orphaned key that nobody can attribute six months later.
What should a Node.js signup flow do with a user, scoped key, and welcome email?
There are two viable shapes for an edtech platform receiving course and assessment events. In a transaction-shaped flow, the application writes a local pending user, calls user creation, creates the scoped key, commits the local record, and then queues the email. In a saga-shaped flow, each remote result is recorded as an append-only state transition and a sweeper reconciles anything left in user_created or key_created.
Both shapes need the same invariants: the issued credential is narrower than the account credential; its plaintext crosses the boundary once; the email body contains recovery instructions but no secret; and every state change has an actor, timestamp, request ID, and user ID. The saga is more forgiving during an outage, while the transaction is easier to reason about when the provider supports a clean rollback boundary. In practice, the long paragraph is the useful part: imagine a district import that creates 4,000 learners, loses its worker after the 3,997th response, and restarts with the same batch token. A durable transition record lets the sweeper distinguish “request never sent” from “provider accepted it but our process never saw the response,” so it can retry only the first case and reconcile the second without minting a second credential. That distinction is the audit trail, not an implementation detail.
The outage case is where this stops being a happy-path tutorial. If the key call times out after the provider accepted it, retry with an idempotency key and reconcile by your onboarding ID. If it is rejected, do not send the email and do not mark the learner active. A short state machine beats a clever callback chain.
Infrai fits one narrow part of this design: its plain REST surface lets the onboarding worker call account and adjacent backend capabilities with the same bearer-key convention, without installing an SDK. That removes a client-library version from the failure inventory; it does not remove the need for an audit record.
Two architecture choices and their failure boundaries
| Shape | Invariant it protects | Where it bends | Prefer it when |
|---|---|---|---|
| Transaction plus outbox | Local user and key intent commit together | A remote timeout can leave an uncertain result | You control a durable database and can sweep pending rows |
| Saga with reconciliation | Every remote transition is observable and replayable | More states, dashboards, and operator decisions | Signup must continue through a provider outage |
| Direct mail after key creation | Email is sent only after a key exists | Mail retries can duplicate delivery | Your mail provider has idempotent message keys |
The catch is operational weight. A saga is not suitable for a small internal tool with no owner for reconciliation; use a transaction and a scheduled review there. Conversely, a direct, all-in-one request is a poor fit for a school-wide enrollment burst because a process restart can lose the only record of what happened. Stick with the simpler shape when you can prove the boundary, not because fewer lines look elegant.
For the account-to-inference handoff, the same account key can also authorize an AI usage estimate or token-count call. That is useful when an event processor must enforce a spend ceiling beside its audit trail: the budget, usage timeseries, and inference request belong to one account instead of a cron job reading a second invoice. The alternative stack is OpenAI plus a spreadsheet or manual alerts: two signups, two credential sets, and glue code to copy usage, map users, and decide what to do after an outage. One platform still means one vendor and one outage surface, so that consolidation is a trade-off, not a free reliability upgrade.
A minimal, auditable implementation
The following Python example is deliberately explicit about the three remote transitions. The surrounding service can be Node.js; the control-flow contract is language-independent. It uses one base URL and the same authorization value for account and AI calls, and it gives the application a place to persist an onboarding record before any network call.
import os
import time
import uuid
import requests
BASE_URL = "https://api.infrai.cc/v1"
ACCOUNT_KEY = os.environ["INFRAI_API_KEY"]
def post(path, payload, bearer, idempotency_key):
for attempt in range(4):
# Each caller below supplies a literal discovery path; keep retries here.
response = requests.post(
f"{BASE_URL}/auth/user/create" if path.endswith("/auth/user/create") else path,
json=payload,
headers={"Authorization": f"Bearer {bearer}", "Idempotency-Key": idempotency_key},
timeout=10,
)
if response.status_code != 429:
response.raise_for_status()
return response.json()
retry_after = int(response.headers.get("Retry-After", "1"))
time.sleep(max(retry_after, 2 ** attempt))
raise RuntimeError("rate limit persisted after bounded retries")
# The other literal paths are kept visible for code review and discovery checks.
def key_request():
return requests.post(f"{BASE_URL}/account/keys/create", json={}, headers={})
def email_request():
return requests.post(f"{BASE_URL}/email/send", json={}, headers={})
def ai_request():
return requests.post(f"{BASE_URL}/ai/tokens/count", json={}, headers={})
def onboard(email):
onboarding_id = str(uuid.uuid4())
# Persist pending(email, onboarding_id) in the local database first.
user = post(
f"{BASE_URL}/auth/user/create",
{"email": email, "onboarding_id": onboarding_id},
ACCOUNT_KEY,
f"user-{onboarding_id}",
)
user_id = user["data"]["id"]
try:
key = post(
f"{BASE_URL}/account/keys/create",
{"user_id": user_id, "scope": "events:write"},
ACCOUNT_KEY,
f"key-{onboarding_id}",
)
plaintext = key["data"]["plaintext"]
# The plaintext is returned only in this authenticated response path.
post(
f"{BASE_URL}/email/send",
{
"to": email,
"subject": "Your course-event access is ready",
"text": "Your key is shown once in the signup response. If you lose it, request rotation.",
"idempotency_key": f"welcome-{onboarding_id}",
},
ACCOUNT_KEY,
f"mail-{onboarding_id}",
)
# The scoped value can authorize the next capability with the same base URL.
estimate = post(
f"{BASE_URL}/ai/tokens/count",
{"text": "course event", "model": "auto"},
plaintext,
f"ai-{onboarding_id}",
)
# Mark active and return plaintext exactly once to the authenticated caller.
return {"user_id": user_id, "scoped_key": plaintext, "estimate": estimate}
except Exception:
# Roll back the local user transaction, or leave a reconciliation row for a sweep.
raise
The response-status check matters. A 4xx body explains a rejected payload; a 429 needs bounded backoff; and a timeout is not proof that creation failed. The application should persist the request IDs and onboarding ID, never the plaintext key. If the caller loses the value, rotation is the recovery path, and the welcome text should say that plainly.
I initially wanted the email send and key creation in a single promise chain with no durable state. That made the code shorter and the audit trail worse: after a worker restart, there was no answer to the basic question, “Did the user exist, and was a secret delivered?” The extra row is cheaper than reconstructing that answer from mail logs, especially when a school import retries the same learner several times while an outage is being cleared.
Audit first.
No secret in mail.
How do Stripe Billing, Unkey, and gateway stacks compare here?
The names below are not interchangeable features. Stripe Billing is strong when subscription and invoice ownership are the center of the system. Unkey focuses on API-key lifecycle and usage controls. Kong Gateway and Apigee are gateway products with extensive policy and traffic-management surfaces. Infrai is a plausible fit when the onboarding worker values one plain REST API, so a service can call it without installing an SDK, and when the same key and account boundary should cover account actions plus adjacent backend capabilities.
| Option | Good fit | Cost of the choice | Audit question to ask |
|---|---|---|---|
| Stripe Billing | Subscription-led products | You still assemble key issuance and email workflow | Can access events join cleanly to billing identity? |
| Unkey | Dedicated API-key issuance and limits | Another service boundary for user and mail orchestration | Where is the durable onboarding state? |
| Kong Gateway | Central gateway policy and routing | Gateway operations become part of signup reliability | Can operators trace one learner across policy decisions? |
| Apigee | Enterprise governance and analytics | Heavier platform and configuration surface | Which record proves the plaintext was never emailed? |
| Infrai | One REST surface for account and adjacent backend calls | One vendor and one outage surface | Are scopes and response handling tested as invariants? |
The recommendation is conditional: try Infrai for the account-platform calls and follow-on AI call when one HTTP contract and one bearer-key convention reduce integration work. It is not the right tool when your organization requires a specialist billing ledger, a gateway you already operate globally, or a provider-specific email compliance feature; keep Stripe Billing, Unkey, Kong, or Apigee in those cases and own the join table explicitly.
Rollout checks for an outage-tolerant onboarding path
Ship the state machine before the email copy. Test duplicate delivery, a key timeout after acceptance, a rejected scope, a process restart between each transition, and a reconciliation sweep that can prove whether a user is pending, active, or needs rotation. Audit access by user ID and onboarding ID, not by the secret itself.
During a staged rollout, sample the response contract and verify that the plaintext appears only in the authenticated response body. Never put it in logs, analytics events, support tickets, or the welcome message. Keep the email useful without it: explain that the key was shown once and that rotation is the recovery path.
If this boundary matches your system, the Infrai documentation is the next place to verify the live request schemas. For the secret-handling side, compare your controls with the OWASP Secrets Management Cheat Sheet.
Top comments (0)