Customers

A customer is the borrower a valuation is made for. Customers belong to exactly one tenant and are never visible across tenants: an id from another shop reads as not found. Phone numbers are unique within a tenant, and so are email addresses — a customer with no email is unaffected, but a non-null address must resolve to a single customer so email-OTP login can identify them.

Every endpoint here requires a staff principal — TENANT_ADMIN or TENANT_EMPLOYEE — with one exception: POST /api/customer-intake is unauthenticated and gated by a signed link instead. See Authentication.

Customer records carry Aadhaar numbers, PAN, bank account details and KYC document URLs. Treat every response on this page as personal data.

List customers

GET /api/customers

Returns your tenant's customers, newest first. Requires a staff principal.

NameTypeRequiredDescription
searchstringNoQuery-string filter. Matches name, bank name (both case-insensitive, substring), phone (substring) and Aadhaar (substring of the query's digits). Omit it to return everything.

There is no pagination and no result cap. A tenant with a large customer book gets the whole book in one response.

curl -G https://goldpro.shop/api/customers \
  --data-urlencode "search=anita" \
  -H "Authorization: Bearer $TOKEN"
[
  {
    "id": "clx8k2p9a0001qw3f7h2mz1yv",
    "name": "Anita Deshmukh",
    "phone": "9876543210",
    "email": "anita@example.com",
    "aadhaar": "123456789012",
    "pan": "ABCDE1234F",
    "address": "14 Laxmi Road, Pune",
    "nomineeName": "Rahul Deshmukh",
    "nomineeRel": "Son",
    "bankName": "State Bank of India",
    "accountNumber": "30271882345",
    "ifsc": "SBIN0001234",
    "status": "Loan Active",
    "phoneVerified": true,
    "phoneVerifiedAt": "2026-07-14T11:02:19.441Z",
    "livePhotoUrl": "https://blob.goldpro.shop/kyc/live-clx8k2p9a.jpg",
    "idCardPhotoUrl": null,
    "aadhaarDocUrl": null,
    "panDocUrl": null,
    "passbookUrl": null,
    "notificationsSeenAt": null,
    "tenantId": "clx8jz1r70000qw3f9k5te0pq",
    "createdAt": "2026-07-14T10:58:03.117Z",
    "updatedAt": "2026-07-16T09:14:22.104Z",
    "valuations": [{ "createdAt": "2026-07-16T09:14:22.104Z" }],
    "_count": { "valuations": 3, "emiRecords": 12 }
  }
]

Each entry is the stored customer row plus two extras: valuations, which carries only the most recent valuation's createdAt so you can show a last-activity date, and _count, with totals for valuations and EMI records.

The legacy password column — a hash left over from the retired portal login — is not returned. Nothing writes it any more, and portal login is email OTP.

StatusErrorCondition
401UnauthorizedNot a staff principal.
500Internal Server ErrorQuery failure.

Create a customer

POST /api/customers

Creates a customer in your tenant. Requires a staff principal.

NameTypeRequiredDescription
namestringYes2–50 characters. Letters, spaces, dots, hyphens and apostrophes only.
phonestringYesNon-digits are stripped, then the result must be a valid 10-digit Indian mobile number starting 6–9.
emailstringNoTrimmed and lowercased. Must be unique within your tenant, compared case-insensitively.
aadhaarstringNoExactly 12 digits, with no spaces or separators.
panstringNoUppercased. Five letters, four digits, one letter, for example ABCDE1234F.
addressstringNoFree text.
nomineeNamestringNo2–50 characters, same character set as name. Requires nomineeRel.
nomineeRelstringNoAt least 2 characters. Letters, spaces, slashes, dots, hyphens and parentheses. Requires nomineeName.
bankNamestringNoRequired when any bank field is present.
accountNumberstringNoNon-digits are stripped, then must be 9–20 digits. Required when any bank field is present.
ifscstringNoUppercased. Four letters, a zero, then six alphanumerics, for example SBIN0001234. Required when any bank field is present.
livePhotoUrlstringNoIn-person photo captured at the counter.
idCardPhotoUrlstringNoID-card sized photo.
aadhaarDocUrlstringNoAadhaar document scan.
panDocUrlstringNoPAN document scan.
passbookUrlstringNoBank passbook photo.

Nominee fields are all-or-nothing: sending one without the other is rejected. Bank fields are the same — bankName, accountNumber and ifsc must arrive together or not at all.

aadhaar and accountNumber are not treated alike. An account number is stripped of separators before validation, so 3027-1882-345 is accepted. An Aadhaar number is validated first and stripped afterwards, so 1234 5678 9012 is rejected and you must send 123456789012.

The phoneVerified flag is resolved server-side from a recent email-OTP verification for this tenant and address; a client-supplied value is ignored. The column name predates the move to email OTP and is reused as a generic identity-verified flag — the interface labels it "Email verified".

curl -X POST https://goldpro.shop/api/customers \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Anita Deshmukh",
    "phone": "9876543210",
    "email": "anita@example.com",
    "aadhaar": "123456789012",
    "pan": "ABCDE1234F",
    "address": "14 Laxmi Road, Pune",
    "nomineeName": "Rahul Deshmukh",
    "nomineeRel": "Son"
  }'

A successful call returns 201 with the created row. The legacy password field is stripped from this response.

{
  "id": "clx8k2p9a0001qw3f7h2mz1yv",
  "name": "Anita Deshmukh",
  "phone": "9876543210",
  "email": "anita@example.com",
  "aadhaar": "123456789012",
  "pan": "ABCDE1234F",
  "address": "14 Laxmi Road, Pune",
  "nomineeName": "Rahul Deshmukh",
  "nomineeRel": "Son",
  "bankName": null,
  "accountNumber": null,
  "ifsc": null,
  "status": "New",
  "phoneVerified": false,
  "phoneVerifiedAt": null,
  "livePhotoUrl": null,
  "idCardPhotoUrl": null,
  "aadhaarDocUrl": null,
  "panDocUrl": null,
  "passbookUrl": null,
  "notificationsSeenAt": null,
  "tenantId": "clx8jz1r70000qw3f9k5te0pq",
  "createdAt": "2026-07-14T10:58:03.117Z",
  "updatedAt": "2026-07-14T10:58:03.117Z"
}

phone is canonicalised to 10 digits before it is stored and before the duplicate check runs, so +91 98765 43210, 098765 43210 and 9876543210 are all the same customer and the second one is rejected rather than written. Send whichever form you have; what comes back is the canonical one.

StatusErrorCondition
401UnauthorizedNot a staff principal.
400A field messageAny validation failure, for example Name must be at least 2 characters long. or IFSC code must be valid (format: 4 letters, 0, 6 alphanumeric characters, e.g., SBIN0001234).
400Please specify the relationship with the nominee.nomineeName sent without nomineeRel.
400Please specify the nominee name.nomineeRel sent without nomineeName.
400Bank name is required when bank details are provided.A bank field was sent without bankName.
400Account number is required when bank details are provided.A bank field was sent without accountNumber.
400IFSC code is required when bank details are provided.A bank field was sent without ifsc.
400Customer with this phone number already existsThe phone is already in your tenant. Also returned when the unique index fires under concurrency.
400A customer with this email already exists.The email is already in your tenant.
500Internal Server ErrorAny other failure.

A duplicate phone is a 400 here but a 409 on POST /api/valuations. Handle both if you create borrowers through either path.

Retrieve a customer

GET /api/customers/{id}

Returns one customer's profile with KYC progress, their valuations and their EMI schedule. Requires a staff principal. EMI due and overdue states are synchronised before the read, so the statuses returned are current.

curl https://goldpro.shop/api/customers/clx8k2p9a0001qw3f7h2mz1yv \
  -H "Authorization: Bearer $TOKEN"
{
  "id": "clx8k2p9a0001qw3f7h2mz1yv",
  "name": "Anita Deshmukh",
  "phone": "9876543210",
  "email": "anita@example.com",
  "address": "14 Laxmi Road, Pune",
  "aadhaar": "123456789012",
  "pan": "ABCDE1234F",
  "bankName": "State Bank of India",
  "accountNumber": "30271882345",
  "ifsc": "SBIN0001234",
  "nomineeName": "Rahul Deshmukh",
  "nomineeRel": "Son",
  "status": "Loan Active",
  "phoneVerified": true,
  "createdAt": "2026-07-14T10:58:03.117Z",
  "kyc": {
    "livePhoto": true,
    "idCard": false,
    "aadhaarDoc": false,
    "panDoc": false,
    "passbook": false
  },
  "kycProgress": { "completed": 1, "total": 5 },
  "valuations": [
    {
      "id": "clx8k9q1b0003qw3f4d8nc2ab",
      "type": "loan",
      "status": "Completed",
      "totalValue": 210450,
      "estimatedLoan": 157837.5,
      "totalNetWeight": 23,
      "goldReceiptNo": "GR-8NC2AB",
      "createdAt": "2026-07-16T09:14:22.104Z"
    }
  ],
  "emiRecords": [
    {
      "id": "clx8k9q1d0005qw3f8m2xt7fg",
      "amount": 14773.17,
      "dueDate": "2026-08-15T09:14:22.104Z",
      "status": "Upcoming",
      "paidAt": null,
      "valuationId": "clx8k9q1b0003qw3f4d8nc2ab"
    }
  ]
}

This is a fixed projection, not the raw row: the document URLs themselves are not returned, only the five booleans in kyc reporting whether each is present, plus their tally in kycProgress. Valuations are ordered newest first, EMI records by due date.

StatusErrorCondition
401UnauthorizedNot a staff principal.
404Customer not foundNo such id in your tenant.

Customer timeline

GET /api/customers/{id}/timeline

Returns one customer's lifecycle as a flat, chronological array, oldest first. Requires a staff principal.

The timeline merges two sources. Events derivable from existing tables are computed at read time and are never stored twice; the rest are durable rows written when the action happens.

TypeSourcedetail
CREATEDComputed from the customer's creation dateNone
VALUATIONComputed from each valuationLoan · ₹1,57,838 or Sell · ₹2,10,450
GOLD_RECEIPTComputed from a valuation's issue dateThe receipt number
EMI_PAIDComputed from each paid installmentThe amount
EMAIL_SENTStored when a report is emailedA short description
WHATSAPP_SENTStored when a WhatsApp message is sentA short description
BANK_SHAREDStored when a report is dispatched to a bankThe bank name
LOAN_APPROVEDStored when a loan is marked approvedNone
STATUS_CHANGEStored when the lifecycle status changesNew → Active
curl https://goldpro.shop/api/customers/clx8k2p9a0001qw3f7h2mz1yv/timeline \
  -H "Authorization: Bearer $TOKEN"
[
  {
    "id": "created-clx8k2p9a0001qw3f7h2mz1yv",
    "type": "CREATED",
    "occurredAt": "2026-07-14T10:58:03.117Z"
  },
  {
    "id": "val-clx8k9q1b0003qw3f4d8nc2ab",
    "type": "VALUATION",
    "occurredAt": "2026-07-16T09:14:22.104Z",
    "detail": "Loan · ₹1,57,838"
  },
  {
    "id": "clx8kc7h2000bqw3f3n8pk4wq",
    "type": "EMAIL_SENT",
    "occurredAt": "2026-07-16T09:22:41.008Z",
    "detail": "Valuation report to customer"
  },
  {
    "id": "receipt-clx8k9q1b0003qw3f4d8nc2ab",
    "type": "GOLD_RECEIPT",
    "occurredAt": "2026-07-16T09:41:18.882Z",
    "detail": "GR-8NC2AB"
  }
]

id is stable but not a database key for computed events: they are prefixed composites such as created-, val-, receipt- and emi-. Stored events carry their own row id. detail is pre-formatted for display and is omitted when there is nothing to say.

A VALUATION event is labelled Loan only when the valuation's type is exactly "loan". Both "sell" and "certificate" valuations are labelled Sell and show their total value.

StatusErrorCondition
401UnauthorizedNot a staff principal.
404Customer not found.No such id in your tenant.
404Customer ID is required.The id segment was blank.
500Internal Server ErrorAny other failure.

Public intake

POST /api/customer-intake

Accepts a customer's own KYC submission from the public intake form. This is the only endpoint on this page that takes no session and no Bearer token. It is protected by a signed, expiring link and by email OTP.

Two gates must both pass. The token is an HMAC over the tenant slug and an expiry, minted on the authenticated staff page at /customers/new; it expires after CUSTOMER_INTAKE_TOKEN_TTL_DAYS days, 14 by default. The submitted email must also carry a recent OTP verification for that tenant, which the customer completes on the form before submitting.

NameTypeRequiredDescription
tenantstringYesThe tenant slug the link was minted for.
tokenstringYesThe signed intake token from the link.
namestringYes2–50 characters, same rules as the staff endpoint.
phonestringYesNon-digits stripped, then must be a valid 10-digit Indian mobile number.
emailstringYesLowercased. Must already be OTP-verified for this tenant.
aadhaarstringNoExactly 12 digits, no separators.
panstringNoUppercased, standard PAN format.
addressstringNoFree text.
nomineeNamestringNoRequires nomineeRel.
nomineeRelstringNoRequires nomineeName.
bankNamestringNoRequired when any bank field is present.
accountNumberstringNoValidated as 9–20 digits after stripping separators, but stored as sent.
ifscstringNoUppercased, standard IFSC format. Required when any bank field is present.

Unlike the staff endpoint, email is mandatory and no KYC media URLs are accepted — documents are captured later at the counter. The created customer's phoneVerified flag is left false even though the email was verified to reach this point.

curl -X POST https://goldpro.shop/api/customer-intake \
  -H "Content-Type: application/json" \
  -d '{
    "tenant": "sai-suppliers",
    "token": "MTc5MTIzNDU2Nzg5MA.9f3c1e...",
    "name": "Anita Deshmukh",
    "phone": "9876543210",
    "email": "anita@example.com",
    "aadhaar": "123456789012",
    "pan": "ABCDE1234F",
    "address": "14 Laxmi Road, Pune"
  }'
{
  "success": true,
  "customer": {
    "id": "clx8k2p9a0001qw3f7h2mz1yv",
    "name": "Anita Deshmukh"
  }
}

Only the new customer's id and name are returned. Nothing else about the tenant or its customer book is exposed to an unauthenticated caller.

StatusErrorCondition
400Tenant is required.tenant absent or blank.
403Invalid customer form link.The token is malformed, expired, or not signed for this tenant.
400A field messageAny validation failure.
400Email address is required.email absent or blank.
404Tenant not found.The slug is unknown, or the tenant is suspended.
403Please verify your email address before submitting.No recent OTP verification for this tenant and address.
400We couldn't save this submission. Please check your details, or contact the branch if you've already registered.A unique-constraint conflict on phone or email.
500Internal Server ErrorAny other failure.

The conflict message is deliberately vague and indistinguishable from a validation failure. Anyone holding an intake link could otherwise submit numbers one at a time and learn which belong to the shop's customers.

There is no per-token revocation. A leaked intake link keeps working until its TTL expires; invalidating one early means rotating NEXTAUTH_SECRET, which signs out every staff, customer and super-admin session at the same time. Keep CUSTOMER_INTAKE_TOKEN_TTL_DAYS short.

Customer statuses

status is a plain string, not an enumerated type. The system writes and reads these seven values, in the order a customer typically moves through them.

ValueMeaning
NewCreated, no valuation yet. Assigned to every customer on creation.
KYC PendingDocuments uploaded, identity not yet verified.
ActiveKYC complete, no running loan.
Loan ActiveHas a running loan or EMI schedule.
OverdueAn EMI is overdue or in default.
ClosedLoan settled or fully completed.
InactiveArchived or dormant.

Inactive is the only status that blocks portal login, and it revokes an existing customer token immediately rather than at expiry. Closed and Overdue both keep portal access. An archived customer is also hidden from the borrower picker, which is why creating a valuation against their phone returns a distinct 409 telling you to reactivate them.

  • Customers and KYC — the customer book, intake links and the portal.
  • Valuations — creating a borrower inline while valuing.
  • EMI — repayment schedules and reminders.