/v1/admin — the surface
an installation automates against with a shared bearer token, from a script, a
CI job, or another service.
Merging any two of these would mean one silently inherits the other’s
middleware, and a customer credential must never be able to reach an operator
route. Keeping them structurally apart makes that a property of the route table
rather than a check somebody has to remember, and there is a test asserting the
customer router answers
404 for /v1/panel/auth/session, /v1/admin/orders,
and /v1/panel/admins.
When to use /v1/admin rather than /v1/panel
Use /v1/admin when the caller is not a browser. It has no session to
establish, no CSRF token to keep in step with rotation, and no cookie jar. It is
the only scripted way to create orders, take payments, record refunds, adjust a
wallet, drive the Remnawave import, and enqueue a fulfillment operation.
Use /v1/panel when the caller is a person, or when you need something
/v1/admin does not expose — the support desk, marketing campaigns, the
catalogue editor, installation settings, the audit trail, risk review. It is
also the only surface with per-capability authorization: an integration that
should be able to read but not refund belongs behind an operator account with a
narrow role, not behind the shared token.
Every state-changing request on this prefix — anything but GET, HEAD, and
OPTIONS — is written to the audit trail after it is handled, with actor type
operator and actor id operator-token, action admin_api.<method> (for
example admin_api.post), category system, the outcome (failure for any
4xx or 5xx), and the route template and status in the metadata. The token
names no person, so the trail cannot say who; it does say that the shared
token, not an operator in the panel, made the change, and it is filterable by
that actor in the audit browser. Reads and requests refused for a wrong token
are not recorded.
Nothing inside this repository calls /v1/admin: the Telegram bot talks to
PostgreSQL and Remnawave in process. It exists purely as an external
integration and automation surface.
Authentication
SetAPP_OPERATOR_TOKEN to a long random secret and send it on every request:
401 unauthorized.
The API requires APP_OPERATOR_TOKEN to build its commerce runtime at all,
alongside a 32-byte APP_DATA_ENCRYPTION_KEY, APP_REMNAWAVE_URL,
APP_REMNAWAVE_TOKEN, and APP_VALKEY_URL. It is not optional even for an
installation that never calls this prefix — see
Configuration.
Because there is no session, several handlers take the acting operator as
data the server cannot verify: operatorId in the body of
PUT /v1/admin/maintenance (400 missing_operator when absent), and
operatorID in manual payment decisions and wallet adjustments. Those values
end up in the record. Supply something meaningful, and do not treat them as
authentication.
Request rules
Content-Type: application/jsonon any request with a body.- Bodies are decoded with unknown fields rejected, and must contain exactly
one JSON value. An extra field “for future use” or trailing content is
400 invalid_request. - Bodies are capped at 1 MiB.
/v1/adminhandlers decode into Go structs without JSON tags, so field names bind case-insensitively:customerID,customerId, andCustomerIDall work. The OpenAPI schemas name them in camelCase; both are accepted.- A server-side handler timeout of 60 seconds applies to every route.
Idempotency keys
Every mutation that creates money, provisioning, or a ledger movement takes anIdempotency-Key request header. These are the operations where a
double-submitted form, a proxy retry, or a redelivered webhook must not produce
two orders, two refunds, two wallet credits, or two Remnawave users.
Generate one unique string per logical action and reuse it verbatim on every
retry of that action. The key is threaded into the domain layer and is what
makes the underlying SQL idempotent; there is no generic middleware-level replay
cache, so a key reused across two different actions is a bug in the caller.
Validation is purely a length check: 8 to 128 characters, no charset rule.
A missing or out-of-range key on /v1/admin is
400 missing_idempotency_key.
The contract marks
Idempotency-Key required on
POST /v1/admin/payments/{paymentID}/reconcile, but the handler never reads
it. Reconciliation is idempotent on its own — it re-polls the provider through
the payment service. Sending the header is harmless./v1/account
answers 400 idempotency_key_required (except POST /v1/account/shop/purchase,
which answers 422), and /v1/panel answers 422 idempotency_key_required and
accepts any non-empty string with no length check. Provider webhooks are
deduplicated by the provider’s own event identifier, not by this header.
Cursor pagination
Lists that can grow without bound return{"items":[…], "nextCursor":"…"} and
accept ?cursor=<value>&pageSize=<n>. An empty nextCursor means the last
page. This is keyset pagination on (timestamp, id) rather than OFFSET:
encoding the sort key keeps a page boundary stable while rows are being
inserted, which an offset cannot promise — an integration walking the audit
trail while new events arrive would otherwise see rows twice or miss them.
Fetch the first page without cursor, pass the returned nextCursor back
verbatim, and stop when it is empty. Treat the value as opaque. It is
encoded three different ways across the codebase — base64url, plaintext, and a
two-parameter cursor + cursorId pair — and round-tripping it is correct in
every case while parsing it is not.
Several /v1/admin listings are not cursor-paginated at all and take a plain
limit:
Problem responses
Every failure across all three prefixes is written by one function and comes back as RFC 9457:Content-Type is application/problem+json. Branch on the last path segment
of type — that is the stable machine-readable code. The status alone cannot
distinguish “promo code expired” from “promo code below cost”, and detail is
prose that changes when somebody improves the wording. title is always the
HTTP status text, never a bespoke title.
request_id is snake_case while every other field in the API is camelCase.
That is what the code writes and what the schema declares.
Codes worth handling explicitly:
The same upstream condition can carry two different codes on two prefixes: a
Remnawave outage during a device listing is
502 remnawave_unavailable on
/v1/panel and 503 upstream_unavailable on /v1/account. A runbook that
treats “Remnawave is down” as one condition has to know both.
Two failures escape the problem shape entirely: the telemetry collector answers
in plain text, and GET /v1/panel/audit/export streams CSV after a 200 has
already been written, so a mid-stream failure truncates the file rather than
producing a problem body.
Request IDs
Every request is assigned an identifier and every response carries it asX-Request-ID. The same value appears as request_id in problem bodies, as
requestId on audit rows, in fulfillment correlation identifiers, and in
customer security events.
Read it off every response and log it. It is the single string that ties “the
customer says the payment failed” to a handler log line, an audit row, a
fulfillment operation, and a Remnawave call, without anybody having to quote a
customer identifier over a support channel.
On /v1/panel, the request ID is also the deduplication unit for a
double-submitted operator form: a subscription operation derives its idempotency
key as panel:<subscriptionID>:<operation>:<requestID>.
Rate limits
Limits are Valkey-backed fixed-window counters. Only one of them applies to/v1/admin:
Nothing else on
/v1/admin is throttled. There is no Retry-After header
and no rate-limit headers on any response, so a client that trips a limit should
back off on its own schedule.
Limits fail closed. If Valkey is unreachable the limiter returns an error
and the request is refused, because an outage must not silently remove the
protection. On the promo path that is 503 rate_limit_unavailable; on the
operator sign-in paths it means sign-in is blocked entirely while Valkey is
down.
The client address behind a limit comes from the trusted-proxy resolution
described in Security. With APP_TRUSTED_PROXIES empty,
X-Forwarded-For is ignored and the socket address is used — which means a
deployment behind a proxy that forgets to set it rate-limits every caller as one
subject.
The X-Operator-Reason header
This header belongs to /v1/panel, not to /v1/admin. It is read on every
/v1/panel request and carries why an operator is making a change: the reason
travels in a header rather than in each body because almost every mutation there
needs one, and threading it through sixty request structs would guarantee one of
them forgets. The value lands in the audit trail and, for several records, in
the record itself.
Send X-Operator-Reason: <1..400 characters> on the /v1/panel mutations that
require one — bulk operation preview, gift revocation, customer status and
lifecycle changes, recording a refund, resolving or cancelling a goods delivery,
the manual maintenance switch, blocklist decisions and appeals, anomaly review,
and adding a customer to the risk allowlist. A missing reason surfaces as
422 invalid_request with the detail “Those details are not valid, or a reason
is required.”
/v1/admin handlers ignore the header. Where they need a rationale they take it
in the body instead — reason on POST /v1/admin/customers/{customerID}/lifecycle,
on POST /v1/admin/orders/{orderID}/cancel, and on PUT /v1/admin/maintenance.
A worked example
Create an order for an existing customer and start a payment against it.checkoutUrl. The provider’s webhook settles the payment
at POST /v1/payments/webhooks/{provider}; nothing further is needed from the
integration. Poll GET /v1/admin/orders/{orderID} if you need to observe the
outcome, and see Orders and payments for the state
machine.
Retrying either call with the same Idempotency-Key returns the record the
first attempt created rather than making a second one. Retrying with a new key
creates a second order.
What /v1/admin covers
Thirty endpoints, grouped by what they do.
Things these deliberately do not return:
GET /v1/admin/webhooksnever returns the raw provider body. It is the provider’s own payload and can contain payment details; “did this arrive and was it accepted” needs only the status and the verification result.GET /v1/admin/jobssummarises job arguments rather than echoing them, and runs the last error through the redactor, so a subscription link or a bearer token in an upstream message never leaves the process.- The lifecycle endpoint applies its change under
Serializableisolation and enforces the customer state machine:suspendonly fromactive,restoreonly fromsuspended,anonymizeonly fromdeletedand only after the retention window has passed.
GET /v1/admin/jobsand the retry/cancel routes answer503 jobs_unavailablein a process with no job client attached. The defaultstatefilter isdead_letter; the other accepted values areretryable,running,available,scheduled, andcompleted.- The durable job store is River, whose schema ships with the Atlas
migrations, so on a stock installation these endpoints read River’s own queue
rows. The
/v1/panel/system/jobsview of fulfillment operations is a different thing, backed by Omniflow’s own tables. GET /v1/admin/outboxreports the count of unpublished domain events and the age of the oldest. Nothing in the current build ever marks an outbox row published, so this figure grows and is not a useful backlog signal. See Observability.
Endpoints that need no credential
Five kinds of route answer without any credential, and each is protected by something other than authentication:/healthz,/livez,/readyz— probes./readyzpublishes a stable classification (timeout,unavailable) rather than a driver message, so it cannot leak a connection string./metrics— served only whenAPP_METRICS_ENABLEDis true, and expected to be restricted at the reverse proxy. Every label is bounded: the route template, never a concrete path with identifiers in it.GET /v1/catalog/plans?locale=ru|en— the anonymous storefront read. Any locale other thanenfalls back toru.POST /v1/payments/webhooks/{provider}— unauthenticated by necessity and protected by provider signature verification. A failed verification is still recorded and answered400 webhook_rejected; a body over 1 MiB is413 payload_too_large; a duplicate provider event resolves to{"status":"duplicate"}.POST /v1/telemetry/events— present only whenAPP_TELEMETRY_COLLECTOR_ENABLEDis true (default false). It is the receiving end an installation would run, not something a normal deployment exposes.
The OpenAPI contract
api/openapi.yaml is the source contract (OpenAPI 3.0.3) and is what the
published API reference renders. Both the Go server types and the TypeScript
bindings are generated from it, and CI fails if a contract change is committed
without regenerating them.
Know these gaps before you generate a client from it:
- It documents 113 of the 195
/v1/panelroutes. Whole areas are absent — installation settings, AI and MCP registries, the support desk, marketing, loyalty, operator OIDC, and the health and maintenance views. The/v1/adminand/v1/accountsurfaces are documented in full. - No operation references the
panelSessionoraccountSessionsecurity scheme. The schemes are defined and there is no root-levelsecurity, so in OpenAPI terms every/v1/paneland/v1/accountoperation reads as unauthenticated. Only the 30/v1/adminoperations carrysecurity: [{operatorToken: []}]. A generated client will not know the panel calls need credentials. - Required permissions appear only in prose descriptions, so no tooling can
surface them. The authoritative answer is the
requirePermission(...)at each route’s mount point. - Responses are not validated against the contract at runtime. Handlers decode into anonymous structs and write hand-built maps; the generated Go server types are imported by nothing. A response can drift from its schema without a test failing.
info.versionreads1.0.0. It tracks the release line rather than a separate contract version, so it does not tell you which of the gaps above have since been closed.- Two operations are documented unconditionally but are conditional in code:
GET /metricsandPOST /v1/telemetry/events. More generally, a/v1/panelor/v1/accountroute whose service is not attached is absent (404), not503— a503would tell an anonymous caller what the operator has configured.
Related pages
Configuration
APP_OPERATOR_TOKEN, APP_TRUSTED_PROXIES, and the rest of the environment.Security
The credential surfaces, what is never logged, and how secrets are sealed.
Orders and payments
The order state machine the worked example drives.
Jobs and queues
What the job endpoints show and what actually retries.