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

StatusMeaningTypical cause
200OKA read succeeded, or a create was an idempotent replay
201CreatedA new record was written
400Bad requestMissing or invalid field, unsupported purity, no gold rate available
401UnauthorizedNo credential, an invalid or expired one, an evicted device — or a valid credential lacking the required role
403ForbiddenA tenant-isolation violation, or a rejected intake link
404Not foundUnknown record or unknown tenant slug
409ConflictA borrower with that phone already exists in your tenant
429Too many requestsA rate-limit bucket is exhausted. Carries Retry-After
500Server errorAn unhandled fault, or NEXTAUTH_SECRET unset
501Not implementedBlob storage is not configured
503UnavailableA 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:

RouteMeaning
/api/admin/templatesA signed-in user who is not a TENANT_ADMIN
/api/blobThe pathname's tenant prefix is not your tenant
/api/customer-intakeAn 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

BucketLimitWindowEndpoint
sa-login:ip:<ip>1015 minPOST /api/system-gate/login
sa-login:email:<email>515 minPOST /api/system-gate/login
mobile-sa-login:ip:<ip>1015 minPOST /api/mobile/auth/superadmin/login
mobile-sa-login:email:<email>515 minPOST /api/mobile/auth/superadmin/login
mobile-staff-login:ip:<ip>2015 minPOST /api/mobile/auth/staff/login
employee-login-failed:email:<email>515 minStaff sign-in, web and native
portal-login:ip:<ip>2015 minPOST /api/tenant-portal/login
portal-login:acct:<slug>:<email>515 minPOST /api/tenant-portal/login
mobile-portal-login:ip:<ip>2015 minPOST /api/mobile/auth/customer/login
mobile-portal-login:acct:<slug>:<email>515 minPOST /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

BucketLimitWindowEndpoint
portal-otp:ip:<ip>101 hourPOST /api/tenant-portal/otp/send
portal-otp:acct:<slug>:<email>51 hourPOST /api/tenant-portal/otp/send
portal-otp:tenant:<tenantId>1001 hourPOST /api/tenant-portal/otp/send
otp-email:ip:<ip>101 hourPOST /api/otp/email/send
otp-email:addr:<tenantId>:<email>51 hourPOST /api/otp/email/send
otp-email:tenant:<tenantId>1001 hourPOST /api/otp/email/send
otp-email-verify:ip:<ip>3010 minPOST /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

BucketLimitWindowEndpoint
upload:user:<userId>401 minPOST /api/upload
ocr:user:<userId>201 minPOST /api/ocr/parse
checkout:tenant:<tenantId>1010 minPOST /api/billing/checkout
coupon-try:tenant:<tenantId>2010 minPOST /api/billing/quote, only when a coupon code is supplied
contact:ip:<ip>101 hourPOST /api/contact
contact:email:<email>31 hourPOST /api/contact
contact:phone:<phone>31 hourPOST /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 last x-forwarded-for hop — 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 for localhost or 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 }
    ]
  }'
NameTypeRequiredDescription
clientRequestIdstringNoIdempotency key. Omit it and every call creates a new valuation
SituationResponse
First request with a given key201 with the created valuation
Replay with the same key200 with the existing valuation
Concurrent replay that loses the insert race200 with the valuation the winner created
No key sent201 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 409 about 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

StatusRetry
429Yes, after the interval in Retry-After
500, 503Yes, with backoff, if the call is idempotent
400, 403, 404, 409No. The request itself is wrong
401Not 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.