CorebanqCorebanq Developer Docs
Uploadsv2Customer Documents

List a customer's uploads with search, sort and pagination

The v2 replacement for GET /v1/uploads/customer/{customer_id}. This route was registered but documented nowhere. Two differences from v1 that change how a client reads it: - it returns the SHARED LIST ENVELOPE — data, total, total_unfiltered, has_more, and keys when stacked — where v1 returns a bare array with no counts; - it takes the shared search/sort/pagination parameters, where v1 takes three flat filters (isSigned, isApproved, tag) and nothing else. Scoping is by LINK, then by grant. The uploads are those linked to the customer; a caller with read-all gets all of them, otherwise the set is intersected with the upload ids the caller may read. An empty intersection is a 200 with an empty envelope, not a 403 — so a caller who may see the customer but none of its uploads gets an empty page rather than a refusal. total_unfiltered IS ALWAYS EQUAL TO total ON THIS ROUTE — do not read it as the pre-search count. The service builds one GORM statement and reuses it: the search predicates are appended to the very object the unfiltered count is then taken from, so both counts run the same SQL. It carries useful information only when no search is given, and then it is the same number as total anyway. has_more is (offset + effective limit) < total, computed off the limit the route actually used. Filterable fields, usable as search.<field>[.operator]: id, file_name, document_type, approval_status, approval_needed, approval_made, signature_status, signature_needed, signature_made, customer_id, content_length, file_extension, metadata, created_at, created_by, modified_at, modified_by, active. Each search.<field> parameter also accepts an operator suffix — search.<field>.<operator>, e.g. search.created_at.gte=2026-01-01 or search.document_type.in=passport,id_card. The suffixed forms are not enumerated here; only the bare equality form is. The reserved search._text.<operator> filter searches the free-text expression described under the search parameter. A REJECTED search.<field> SPLITS INTO TWO OUTCOMES, and the difference is the field NAME, not whether the field exists: - a name that is not a valid identifier — search.foo-bar=1 — fails validFieldNamePattern and is a 400 query_m.invalid_field; - a well-formed name that is simply not in the list above — search.bogus=x — is a 500 carrying query_m.invalid_search_field. It is rejected, not dropped, but the route builds its total query before ApplySearchAndSort, and ApplySearchAndSort is the only place that raises that error to a 400. The same typo is a 400 on other get-all endpoints. Both checks run only when the caller has at least one readable upload: with an empty permitted set the request short-circuits to the empty 200 before the search is parsed.

GET
/v2/uploads/customer/{customer_id}

Authorization

bearerAuth
AuthorizationBearer <token>

In: header

Path Parameters

customer_id*string

Customer whose uploads to list. A value that does not parse is 400 — written by BadRequest400 with no AppError, so the body carries the helper's default code rather than an uploads. one. A value that parses but is the all-zero UUID is a different 400: ensureCustomerAccessCtx rejects it with uploads.invalid_upload_input.

Query Parameters

limit?integer

Default 10, over 100 clamps to 100, non-positive becomes 10. -1 is NOT the skip-data-fetching sentinel on this route: the shared parser preserves the value, but the service reads it through query.GetLimit, which turns any non-positive value into 10, and then fetches the page. Asking for limit=-1 returns ten records, not a count-only response. A non-integer is 400.

offset?integer

Default 0; a negative value is clamped to 0. A non-integer is 400.

sort?string

Comma-separated fields, "-" prefix for descending. THERE IS NO DEFAULT ORDER: ApplySearchAndSort sorts only when the parameter is present, so omitting sort emits no ORDER BY at all and the page order is whatever PostgreSQL returns — pagination over an unsorted result may repeat or skip rows. Sending an empty sort= does apply the shared created_at descending default. A field outside the filterable list is NOT a 400 here: ParseSortFields builds query_m.invalid_sort_field with a 400, but ApplySearchAndSort rewraps it as items_m.item_failed_to_list with no code, so the client receives a 500.

stack?string

Group the page by this field instead of returning a flat list. When set and valid, data is an OBJECT keyed by the formatted field value rather than an array, and keys lists those keys in order. A "-" prefix reverses the key order; field[format] applies a format rule. Unlike sort and search, stack is validated even when the caller has no readable uploads: an unknown field is 400 query_m.invalid_field, a missing closing bracket is 400 query_m.invalid_format, and a bad rule on a number-typed field is 400 query_m.invalid_format_rule. FORMATS ARE ONLY VALIDATED FOR date- AND number-typed fields: created_at and modified_at are typed timestamp, so stack=created_at[anything] is accepted unchecked and the key falls back to Go's default rendering of the value.

distinct?string

Accepted by the shared parser and then ignored: this route applies no DISTINCT.

search?string

Free-text term, equivalent to search._text.like. It matches only the STRING-typed filterable columns — file_name, document_type, approval_status, signature_status, file_extension — because no field in this route's field map is marked searchable and the shared fallback covers root-level string columns only. UUID, numeric, timestamp, boolean and metadata (jsonb) columns are not searched. When the term is present the response carries metadata["search._text.props"], listing which of those columns actually matched on the returned page.

search_text?string

Alias of search; the shared parser folds both names onto the same value.

fill_gaps?string

Accepted by the shared parser and carried into the custom parameters; this route does not read it.

filter?string

Accepted by the shared parser, which requires it to be valid JSON — anything else is 400 common.invalid_input. This route does not read the parsed value.

search.id?string
search.file_name?string
search.document_type?string
search.approval_status?string
search.approval_needed?integer
search.approval_made?integer
search.signature_status?string
search.signature_needed?integer
search.signature_made?integer
search.customer_id?string
search.content_length?integer
search.file_extension?string
search.metadata?string
search.created_at?string
search.created_by?string
search.modified_at?string
search.modified_by?string
search.active?boolean

Response Body

application/json

application/json

application/json

application/json

application/json

application/json

application/json

curl -X GET "https://example.com/v2/uploads/customer/497f6eca-6276-4993-bfeb-53cbbbba6f08"
{
  "data": [
    {
      "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
      "file_name": "string",
      "document_type": "bylaws",
      "approval_status": "pending",
      "approval_needed": 0.1,
      "approval_made": 0.1,
      "signature_status": "pending",
      "signature_needed": 0.1,
      "signature_made": 0.1,
      "customer_id": "160c0c4b-9966-4dc1-a916-8407eb10d74e",
      "content_length": 0,
      "file_extension": "string",
      "active": true,
      "metadata": {},
      "created_at": "2019-08-24T14:15:22Z",
      "modified_at": "2019-08-24T14:15:22Z",
      "modified_by": "e8d4374d-93a1-4e98-a6c6-fdcf00c5059f",
      "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4"
    }
  ],
  "total": 0,
  "total_unfiltered": 0,
  "has_more": true,
  "keys": [
    "string"
  ],
  "metadata": {}
}

{
  "status": 400,
  "message": "Invalid input",
  "code": "common.invalid_input",
  "class": "validation"
}

{
  "status": 401,
  "message": "Unauthorized",
  "code": "common.unauthorized",
  "class": "business"
}
{
  "status": 403,
  "message": "Unauthorized",
  "code": "uploads.unauthorized",
  "class": "business"
}

{
  "status": 429,
  "message": "Rate limit for 203.0.113.7 to /v1/uploads exceeded.",
  "code": "rate_limits_m.exceeded",
  "class": "temporary",
  "retryable": true
}

{
  "status": 500,
  "message": "Internal server error",
  "code": "common.server_error",
  "class": "business"
}

{
  "overall_status": "unhealthy",
  "message": "Service is shutting down",
  "timestamp": "2026-08-27T15:04:05Z"
}