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.
| Name | Type | Required | Description |
|---|---|---|---|
search | string | No | Query-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.
| Status | Error | Condition |
|---|---|---|
| 401 | Unauthorized | Not a staff principal. |
| 500 | Internal Server Error | Query failure. |
Create a customer
POST /api/customers
Creates a customer in your tenant. Requires a staff principal.
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 2–50 characters. Letters, spaces, dots, hyphens and apostrophes only. |
phone | string | Yes | Non-digits are stripped, then the result must be a valid 10-digit Indian mobile number starting 6–9. |
email | string | No | Trimmed and lowercased. Must be unique within your tenant, compared case-insensitively. |
aadhaar | string | No | Exactly 12 digits, with no spaces or separators. |
pan | string | No | Uppercased. Five letters, four digits, one letter, for example ABCDE1234F. |
address | string | No | Free text. |
nomineeName | string | No | 2–50 characters, same character set as name. Requires nomineeRel. |
nomineeRel | string | No | At least 2 characters. Letters, spaces, slashes, dots, hyphens and parentheses. Requires nomineeName. |
bankName | string | No | Required when any bank field is present. |
accountNumber | string | No | Non-digits are stripped, then must be 9–20 digits. Required when any bank field is present. |
ifsc | string | No | Uppercased. Four letters, a zero, then six alphanumerics, for example SBIN0001234. Required when any bank field is present. |
livePhotoUrl | string | No | In-person photo captured at the counter. |
idCardPhotoUrl | string | No | ID-card sized photo. |
aadhaarDocUrl | string | No | Aadhaar document scan. |
panDocUrl | string | No | PAN document scan. |
passbookUrl | string | No | Bank 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.
| Status | Error | Condition |
|---|---|---|
| 401 | Unauthorized | Not a staff principal. |
| 400 | A field message | Any 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). |
| 400 | Please specify the relationship with the nominee. | nomineeName sent without nomineeRel. |
| 400 | Please specify the nominee name. | nomineeRel sent without nomineeName. |
| 400 | Bank name is required when bank details are provided. | A bank field was sent without bankName. |
| 400 | Account number is required when bank details are provided. | A bank field was sent without accountNumber. |
| 400 | IFSC code is required when bank details are provided. | A bank field was sent without ifsc. |
| 400 | Customer with this phone number already exists | The phone is already in your tenant. Also returned when the unique index fires under concurrency. |
| 400 | A customer with this email already exists. | The email is already in your tenant. |
| 500 | Internal Server Error | Any 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.
| Status | Error | Condition |
|---|---|---|
| 401 | Unauthorized | Not a staff principal. |
| 404 | Customer not found | No 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.
| Type | Source | detail |
|---|---|---|
CREATED | Computed from the customer's creation date | None |
VALUATION | Computed from each valuation | Loan · ₹1,57,838 or Sell · ₹2,10,450 |
GOLD_RECEIPT | Computed from a valuation's issue date | The receipt number |
EMI_PAID | Computed from each paid installment | The amount |
EMAIL_SENT | Stored when a report is emailed | A short description |
WHATSAPP_SENT | Stored when a WhatsApp message is sent | A short description |
BANK_SHARED | Stored when a report is dispatched to a bank | The bank name |
LOAN_APPROVED | Stored when a loan is marked approved | None |
STATUS_CHANGE | Stored when the lifecycle status changes | New → 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.
| Status | Error | Condition |
|---|---|---|
| 401 | Unauthorized | Not a staff principal. |
| 404 | Customer not found. | No such id in your tenant. |
| 404 | Customer ID is required. | The id segment was blank. |
| 500 | Internal Server Error | Any 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.
| Name | Type | Required | Description |
|---|---|---|---|
tenant | string | Yes | The tenant slug the link was minted for. |
token | string | Yes | The signed intake token from the link. |
name | string | Yes | 2–50 characters, same rules as the staff endpoint. |
phone | string | Yes | Non-digits stripped, then must be a valid 10-digit Indian mobile number. |
email | string | Yes | Lowercased. Must already be OTP-verified for this tenant. |
aadhaar | string | No | Exactly 12 digits, no separators. |
pan | string | No | Uppercased, standard PAN format. |
address | string | No | Free text. |
nomineeName | string | No | Requires nomineeRel. |
nomineeRel | string | No | Requires nomineeName. |
bankName | string | No | Required when any bank field is present. |
accountNumber | string | No | Validated as 9–20 digits after stripping separators, but stored as sent. |
ifsc | string | No | Uppercased, 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.
| Status | Error | Condition |
|---|---|---|
| 400 | Tenant is required. | tenant absent or blank. |
| 403 | Invalid customer form link. | The token is malformed, expired, or not signed for this tenant. |
| 400 | A field message | Any validation failure. |
| 400 | Email address is required. | email absent or blank. |
| 404 | Tenant not found. | The slug is unknown, or the tenant is suspended. |
| 403 | Please verify your email address before submitting. | No recent OTP verification for this tenant and address. |
| 400 | We 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. |
| 500 | Internal Server Error | Any 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. KeepCUSTOMER_INTAKE_TOKEN_TTL_DAYSshort.
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.
| Value | Meaning |
|---|---|
New | Created, no valuation yet. Assigned to every customer on creation. |
KYC Pending | Documents uploaded, identity not yet verified. |
Active | KYC complete, no running loan. |
Loan Active | Has a running loan or EMI schedule. |
Overdue | An EMI is overdue or in default. |
Closed | Loan settled or fully completed. |
Inactive | Archived 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.
Related
- Customers and KYC — the customer book, intake links and the portal.
- Valuations — creating a borrower inline while valuing.
- EMI — repayment schedules and reminders.