Errors
GOLD PRO reports failures with an HTTP status and a small JSON body. This page covers the envelope, what each status actually means in this API (which is not always what you would assume), the rate limits you can hit, and the one endpoint that deduplicates retries.
The error envelope
Every JSON error carries a single error key holding a human-readable message:
{ "error": "No gold rate available. Please try again shortly." }
A few also carry a machine-readable code:
{
"error": "Signed in on another device.",
"code": "SESSION_SUPERSEDED"
}
code is the exception rather than the rule — SESSION_SUPERSEDED is currently
the only one. Branch on the status, plus code where it is present.
Do not match on message text. Messages are written for people, they are localized in the interface, and they change. Several are deliberately vague to avoid disclosing whether an account exists.
Two responses fall outside the envelope, both rejected before any handler runs: a
request to an unusable tenant subdomain returns plain-text Not found with
404, and two of the cron routes answer plain-text Unauthorized.
Status codes
| Status | Meaning | Typical cause |
|---|---|---|
200 | OK | A read succeeded, or a create was an idempotent replay |
201 | Created | A new record was written |
400 | Bad request | Missing or invalid field, unsupported purity, no gold rate available |
401 | Unauthorized | No credential, an invalid or expired one, an evicted device — or a valid credential lacking the required role |
403 | Forbidden | A tenant-isolation violation, or a rejected intake link |
404 | Not found | Unknown record or unknown tenant slug |
409 | Conflict | A borrower with that phone already exists in your tenant |
429 | Too many requests | A rate-limit bucket is exhausted. Carries Retry-After |
500 | Server error | An unhandled fault, or NEXTAUTH_SECRET unset |
501 | Not implemented | Blob storage is not configured |
503 | Unavailable | A Razorpay webhook arrived while its secret is unset |
401 appears where you would expect 403
This is the most common surprise in this API. Most routes collapse "not
authenticated" and "authenticated but wrong role" into a single 401. An
authenticated TENANT_EMPLOYEE calling POST /api/billing/checkout,
POST /api/admin/gold-rates, or GET /api/admin/banks gets 401, not 403 —
those routes check isTenantAdmin and return 401 on any failure.
403 is used in only three places:
| Route | Meaning |
|---|---|
/api/admin/templates | A signed-in user who is not a TENANT_ADMIN |
/api/blob | The pathname's tenant prefix is not your tenant |
/api/customer-intake | An invalid signed link, or an email not yet verified |
The practical consequence: do not infer from a 401 that your token is bad.
A client that reflexively discards its credential on any 401 will sign users
out the moment an employee touches an admin-only route. Check the role the route
requires before you re-authenticate.
Which layer rejected you
Two layers can produce a 401, and they mean different things. The edge
pre-filter rejects before your handler runs; it emits exactly:
{ "error": "Unauthorized" }
Several handlers return that same string, so the two are not reliably distinguishable from the response alone. When you need to know what happened to a Bearer token, ask:
curl https://goldpro.shop/api/mobile/auth/session-status \
-H "Authorization: Bearer $TOKEN"
It always answers 200 with ACTIVE, SESSION_SUPERSEDED, or INVALID. That
tells you whether to show a login screen, tell the user another device took over,
or treat the failure as a permissions problem rather than a session problem. See
Authentication for the full model.
If NEXTAUTH_SECRET is unset the edge returns 500 with
{ "error": "Server auth is not configured" } for protected /api routes.
Authentication fails closed rather than falling open.
Rate limits
Limits are a fixed window backed by the database. Count-and-decide happens in a single atomic statement, so concurrent requests cannot slip through a read-then-write gap. When a stored window has expired the counter resets and a fresh window starts.
The limiter fails closed: one that cannot run must not silently disable brute-force protection. Every endpoint using it needs the database for its underlying work anyway, so failing closed costs no availability a database outage had not already taken.
A 429 looks like this, with Retry-After in whole seconds:
HTTP/1.1 429 Too Many Requests
Retry-After: 412
Content-Type: application/json
{ "error": "Too many attempts. Please try again later." }
Sign-in
| Bucket | Limit | Window | Endpoint |
|---|---|---|---|
sa-login:ip:<ip> | 10 | 15 min | POST /api/system-gate/login |
sa-login:email:<email> | 5 | 15 min | POST /api/system-gate/login |
mobile-sa-login:ip:<ip> | 10 | 15 min | POST /api/mobile/auth/superadmin/login |
mobile-sa-login:email:<email> | 5 | 15 min | POST /api/mobile/auth/superadmin/login |
mobile-staff-login:ip:<ip> | 20 | 15 min | POST /api/mobile/auth/staff/login |
employee-login-failed:email:<email> | 5 | 15 min | Staff sign-in, web and native |
portal-login:ip:<ip> | 20 | 15 min | POST /api/tenant-portal/login |
portal-login:acct:<slug>:<email> | 5 | 15 min | POST /api/tenant-portal/login |
mobile-portal-login:ip:<ip> | 20 | 15 min | POST /api/mobile/auth/customer/login |
mobile-portal-login:acct:<slug>:<email> | 5 | 15 min | POST /api/mobile/auth/customer/login |
employee-login-failed is different in kind from the rest. It counts only
failed password attempts, and a successful sign-in deletes the bucket. Five
failures lock that email for 15 minutes, and the lock is checked before the
password comparison runs, so a locked account fails fast. It surfaces as a 401
with a message naming the remaining minutes, not as a 429.
Email OTP
| Bucket | Limit | Window | Endpoint |
|---|---|---|---|
portal-otp:ip:<ip> | 10 | 1 hour | POST /api/tenant-portal/otp/send |
portal-otp:acct:<slug>:<email> | 5 | 1 hour | POST /api/tenant-portal/otp/send |
portal-otp:tenant:<tenantId> | 100 | 1 hour | POST /api/tenant-portal/otp/send |
otp-email:ip:<ip> | 10 | 1 hour | POST /api/otp/email/send |
otp-email:addr:<tenantId>:<email> | 5 | 1 hour | POST /api/otp/email/send |
otp-email:tenant:<tenantId> | 100 | 1 hour | POST /api/otp/email/send |
otp-email-verify:ip:<ip> | 30 | 10 min | POST /api/otp/email/verify |
The per-tenant caps bound total email spend; the per-address caps prevent inbox bombing. Attempts against a single OTP challenge are separately capped inside the verification helper.
Work routes
| Bucket | Limit | Window | Endpoint |
|---|---|---|---|
upload:user:<userId> | 40 | 1 min | POST /api/upload |
ocr:user:<userId> | 20 | 1 min | POST /api/ocr/parse |
checkout:tenant:<tenantId> | 10 | 10 min | POST /api/billing/checkout |
coupon-try:tenant:<tenantId> | 20 | 10 min | POST /api/billing/quote, only when a coupon code is supplied |
contact:ip:<ip> | 10 | 1 hour | POST /api/contact |
contact:email:<email> | 3 | 1 hour | POST /api/contact |
contact:phone:<phone> | 3 | 1 hour | POST /api/contact |
POST /api/valuations has no rate limit of its own. The capture path is bounded
where the cost is — uploads and OCR are capped per user.
Re-pricing a quote is not throttled; only testing a coupon code is, because flipping between monthly and yearly is not an attack.
Details worth knowing
- The client IP comes from
x-real-ip, falling back to the lastx-forwarded-forhop — never the first, which a caller can set freely to rotate their own bucket key. - Limits are skipped in development and test, when
DISABLE_RATE_LIMITS=true, and forlocalhostor unresolved IPs. Do not tune your client against local behaviour. - Under heavy contention a counter may pass its maximum within a window. The allow/deny decision uses the value the atomic statement returned, and the remaining count is clamped at zero.
Idempotency
One endpoint deduplicates retries: POST /api/valuations, keyed on
clientRequestId.
It exists because the tenant app queues valuations captured without a connection and replays them on reconnect. If a response is lost in transit, the retry must not produce a second valuation for the same ornaments.
curl -X POST https://goldpro.shop/api/valuations \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clientRequestId": "9f1c2d64-5b8e-4a17-9d3e-6c0b7a2f4e81",
"customerId": "clx4m2p9q0003qw8h1v6r8t5e",
"type": "loan",
"ornaments": [
{ "type": "Bangle", "quantity": 2, "purity": "22K", "grossWeight": 24.4, "deduction": 1.2 }
]
}'
| Name | Type | Required | Description |
|---|---|---|---|
clientRequestId | string | No | Idempotency key. Omit it and every call creates a new valuation |
| Situation | Response |
|---|---|
| First request with a given key | 201 with the created valuation |
| Replay with the same key | 200 with the existing valuation |
| Concurrent replay that loses the insert race | 200 with the valuation the winner created |
| No key sent | 201 every time. No deduplication |
Two mechanisms back this. A lookup before the write catches the ordinary retry,
and a unique index (Valuation_clientRequestId_key, added in the
20260622010000_valuation_idempotency migration) catches the concurrent race —
the handler converts that constraint violation back into a 200 carrying the row
the winner wrote.
The unique index is global, not per-tenant, while the pre-write lookup is scoped to your tenant. Use a UUID. A key colliding with another tenant's would miss the lookup, hit the constraint, and surface as a misleading
409about a duplicate borrower phone.
Generate a fresh key for every distinct valuation and keep it for as long as you
might retry. Reusing a key returns the original valuation rather than creating a
new one. The value is stored on Valuation.clientRequestId and comes back on the
record.
Retrying safely
| Status | Retry |
|---|---|
429 | Yes, after the interval in Retry-After |
500, 503 | Yes, with backoff, if the call is idempotent |
400, 403, 404, 409 | No. The request itself is wrong |
401 | Not until you re-authenticate — and confirm the route's required role first |
POST /api/valuations with a clientRequestId is the only write you can retry
blindly. Every other POST creates a record on each call, so a retry after an
uncertain failure risks a duplicate. Where an endpoint has a natural key — a
borrower's phone within a tenant, for instance — the constraint will catch you
with a 409 rather than writing twice.
For the endpoint-level contracts, continue to Valuations, Customers, or Gold rates.