Description
Purpose and use
Uploads store documents and files needed by onboarding, KYB, KYC, QES, invoices, customer service, and profile workflows. They preserve evidence, approval state, signature status, and customer or record ownership.
Who uses this. Onboarding staff, compliance officers, customer service, document operations, and customers use uploads when submitting, reviewing, approving, or retrieving documents.
How it works. Each upload has a document type, record owner, customer scope, validation result, storage reference, and optional signature or approval state. Configured document types decide which files are accepted.
What users do. Users upload a document, review metadata and status, replace or patch allowed details, approve or reject where configured, and retrieve files for downstream workflows.
Outcomes and side effects. Uploads create customer evidence and can unblock KYB, QES, invoice OCR, avatar display, or customer support actions. They do not approve the parent process unless that workflow records completion.
Related manuals: KYB, QES, PDFGen, Invoices.
Overview
The Uploads API provides functionality for managing file uploads and documents:
- File upload and storage
- Document type management
- File validation
- Signature tracking
- Approval workflow
- Avatar management
- Customer document management
Core Concepts
Document Types
Document type codes are config-driven (uploads.documents in uploads.yaml). They are not a fixed API enum; invalid codes return 400 with uploads.invalid_document_type and an allowed_types parameter.
Common codes (deployment may add or omit keys):
| Code | Purpose |
|---|---|
bylaws | Bylaws with beneficiary information |
articles_of_association | Articles of association |
license | License documents |
customer_agreement | Customer agreement |
corporate_structure | Corporate structure |
declaration_of_shareholders | Declaration of shareholders |
statutes | Statutes or founding deed |
tariffs | Tariff documents |
account_statements | Account statements |
other | Other / uncategorized |
avatar | Avatar (dedicated avatar endpoints) |
Approval and signature status values are also defined under uploads.approval_status and uploads.signature_status in the same config file.
File Validation
- Supported formats: PDF, JPEG, PNG, JPG
- Size limits: 512 bytes (min) to 20MB (max)
- Content type validation
- File extension validation
Document Status
Approval Status
pending: Awaiting approvalapproved: Document approvedattached: No approval needed
Signature Status
pending: Awaiting signaturessigned: Document signednot_required: No signature needed
Avatar Types
customer: Customer avatarsuser: User avatars
Endpoints
File Management
Create File
POST /v1/uploads
Upload a new file.
Request Body:
{
"file_name": "customer_agreement.pdf",
"content": "base64_encoded_content",
"document_type": "customer_agreement",
"approval_needed": 1,
"approval_made": 0,
"signature_needed": 1,
"signature_made": 0,
"active": true,
"metadata": {
"department": "legal",
"priority": "high"
}
}Response:
{
"id": "uuid",
"file_name": "customer_agreement.pdf",
"document_type": "customer_agreement",
"approval_status": "pending",
"approval_needed": 1,
"approval_made": 0,
"signature_status": "pending",
"signature_needed": 1,
"signature_made": 0,
"content_length": 15360,
"file_extension": "application/pdf",
"active": true,
"metadata": {
"department": "legal",
"priority": "high",
"document_type_localized": "Customer Agreement",
"approval_status_localized": "Pending Approval",
"signature_status_localized": "Pending Signature"
},
"created_at": "2024-03-21T10:00:00Z",
"created_by": "uuid"
}Get All Files
GET /v1/uploads
List all files with optional filtering.
Query Parameters:
- isSigned: Filter signed documents
- isApproved: Filter approved documents
Get File
GET /v1/uploads/{id}
Get file metadata.
Get File Content
GET /v1/uploads/{id}/content
Get file content.
Response:
{
"file_name": "customer_agreement.pdf",
"content": "base64_encoded_content",
"file_extension": "application/pdf",
"document_type": "customer_agreement"
}Update File
PUT /v1/uploads/{id}
Replace upload metadata (file_name, approval/signature counts, active, metadata). To change document_type, use PATCH below.
Request Body:
{
"file_name": "updated_customer_agreement.pdf",
"approval_needed": 2,
"signature_needed": 2,
"metadata": {
"department": "legal",
"status": "revised"
},
"active": true
}Patch File Metadata
PATCH /v1/uploads/{id}
Change upload metadata partially. Use this to reclassify a document (document_type) without a full PUT body.
Request Body:
{
"document_type": "bylaws"
}document_type must be a valid code from uploads.documents in app config. The response includes updated document_type_localized and catalog icon in metadata.
Upload change history
GET /v1/uploads/{id}/history
Returns blame audit rows for one upload (create, update, delete, type change, signature adjustments). Requires read permission on the upload.
Customer upload history counts
GET /v1/uploads/customer/{customer_id}/history-counts
Returns { "counts": { "": , ... } } for uploads linked to the customer that the caller may read. Used by Configurator to show history icons without calling Internal-only /v1/blame/count.
Delete File
DELETE /v1/uploads/{id}
Delete a file.
Adjust Signature Count
PATCH /v1/uploads/{id}/signature
Increment or decrement the signature count (adjust_by). This is separate from PATCH /v1/uploads/{id}, which updates metadata such as document_type.
Request Body:
{
"adjust_by": 1
}Customer Document Management
Create Customer File
POST /v1/uploads/customer/{customer_id}
Upload a file for a specific customer. Requires update permission on the customers record identified by customer_id, plus create permission on uploads. The upload is linked to that customer; customer_id in the URL is authoritative (body cannot target another customer).
Get Customer Files
GET /v1/uploads/customer/{customer_id}
Get all files for a customer. Requires read permission on the customer, then returns only uploads linked to that customer that the caller may read.
Query Parameters:
- isSigned: Filter signed documents
- isApproved: Filter approved documents
- tag: Filter by document tag
List customer uploads (v2)
GET /v2/uploads/customer/{customer_id}
The v2 replacement for the route above. It was registered but documented nowhere — neither in this manual nor in the OpenAPI file.
Two differences that change how a client reads it:
- it returns the shared list envelope —
data,total,total_unfiltered,has_more, andkeyswhen 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 linked to the customer, intersected with the upload
ids the caller may read unless the caller has read-all. An empty intersection is a 200 with an
empty envelope, not a 403 — a caller who may see the customer but none of its uploads gets an
empty page rather than a refusal.
total_unfiltered reads as the pre-search count but is always equal to total on this route.
The service builds one GORM statement and reuses it, so the search predicates are appended to the
very object the unfiltered count is then taken from; both counts run the same SQL. Do not render it
as the denominator of an "n of N". has_more is (offset + effective limit) [.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.
Bare search= is free text over the string columns only — file_name, document_type,
approval_status, signature_status, file_extension. Nothing marks a field searchable in this
route's field map, so the shared parser falls back to root-level string columns; UUID, numeric,
timestamp, boolean and metadata (jsonb) columns are not searched. When a term is given, the
response carries metadata["search._text.props"] naming the columns that matched on the page.
Three query parameters whose failure mode is not what a client expects
- A well-formed but unknown
search.is a500, not a400and not a silent drop. The shared parser rejects it, but this route builds its total query beforeApplySearchAndSort— andApplySearchAndSortis the only place that raisesquery_m.invalid_search_fieldto a400. So the same typo that is a400on other get-all endpoints is a server error here. The check runs only when the caller has at least one readable upload: with an empty permitted set the request short-circuits to the empty200before the search is parsed. The split is on the field name, not on whether the field exists:search.foo-bar=1fails the identifier pattern first and is a plain400 query_m.invalid_field. Only a name that looks like a field but is not one reaches the uncoded path. Everything else the search parser rejects — an unknown.operatorsuffix, twosearch._text.*operators at once, a valued operator with no operand, a non-UUID value on a UUID field — keeps its400. - An unknown
sortfield is also a500.ParseSortFieldsbuildsquery_m.invalid_sort_fieldwith a400, andApplySearchAndSortrewraps it asitems_m.item_failed_to_listwithout a code, whichHandleAppErrorWithCodeanswers as500. limit=-1is not the skip-data-fetching sentinel here. The parser preserves-1, but the service reads the value throughquery.GetLimit, which turns any non-positive value into10, and then fetches the page. A client asking for a count-only response gets ten records.- Omitting
sortleaves the page unordered.ApplySearchAndSortsorts only when the parameter is present, and the service adds no ordering of its own — so with nosortthere is noORDER BYat all and pagination over the result may repeat or skip rows. The sharedcreated_at DESCdefault applies only to an emptysort=, never to an absent one.
stack is the one parameter validated regardless of the permitted set: 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 checked for
date- and number-typed fields, and created_at/modified_at are typed timestamp — so
stack=created_at[anything] is accepted unchecked and the key falls back to Go's default rendering.
When stack is set, data is an object keyed by the stack value rather than an array, and
keys lists those keys in order. distinct, fill_gaps and filter are accepted by the shared
parser and then ignored by this route — though a filter that is not valid JSON is still a 400.
The 403 on this route is uploads.unauthorized, not common.forbidden; the nil customer_id
400 is uploads.invalid_upload_input, not common.invalid_input.
Get Customer File By Type
GET /v1/uploads/customer/{customer_id}/file/type/{file_type}
Get customer files of a specific type.
Error Codes
Message keys are defined in (and related packages). API error responses use the wire code string (first column below), not Go constant names.
Upload module (uploads.*)
| Wire code | Go constant (errs) | Description |
|---|---|---|
uploads.failed_to_serialize | ErrMsgUploadFailedToSerialize | Failed to serialize upload data |
uploads.duplicate_entity | ErrMsgUploadDuplicateEntity | Duplicate upload exists |
uploads.unauthorized | ErrMsgUploadUnauthorized | Insufficient record or scoped access (upload/customer) |
uploads.database_error | ErrMsgUploadDatabaseError | Upload database operation failed |
uploads.record_not_found | ErrMsgUploadRecordNotFound | Upload record not found |
uploads.invalid_file_extension | ErrMsgUploadInvalidFileExtension | File extension or content type not allowed |
uploads.invalid_document_type | ErrMsgInvalidDocumentType | document_type not in uploads.documents config |
uploads.invalid_upload_input | ErrMsgInvalidUploadInput | Invalid or empty request/patch body |
uploads.failed_to_decode_data | ErrMsgUploadFailedToDecodeData | Failed to decode base64 file content |
uploads.file_too_large | ErrMsgUploadFileTooLarge | File exceeds configured max size |
uploads.file_too_small | ErrMsgUploadFileTooSmall | File below configured min size |
uploads.user_not_found | ErrMsgUploadUserNotFound | User not found (upload context) |
uploads.invalid_approval_status | ErrMsgInvalidApprovalStatus | Invalid approval status |
uploads.invalid_signature_status | ErrMsgInvalidSignatureStatus | Invalid signature status |
uploads.user_already_signed | ErrMsgUserAlreadySigned | User has already signed this document |
uploads.document_no_signature_count | ErrMsgNoSignatureCount | Document has no signature count |
uploads.upload_not_found | ErrMsgUploadNotFound | Upload not found (signature flow) |
uploads.approval_status_error | ErrMsgApprovalStatusError | Approval status transition error |
uploads.failed_to_fetch_upload | ErrMsgFailedToFetchUpload | Failed to load upload |
uploads.metadata_tag_required | ErrMsgUploadMetadataTagRequired | Customer file list metadata filter missing tag |
uploads.metadata_tag_invalid | ErrMsgUploadMetadataTagInvalid | Customer file list metadata tag must be a string array |
Related codes (other modules, used by uploads)
| Wire code | Go constant (errs) | Description |
|---|---|---|
customers_m.not_found | MsgCustomerNotFound | Customer not found |
customers.principal_not_found | MsgPrincipalNotFound | Customer principal not found |
common.forbidden | MsgForbidden | Forbidden (e.g. avatar owner access) |
rbac_m.failed_to_grant_access | MsgFailedToGrantAccess | Failed to grant record access after create |
Refusals the middleware writes before the handler runs
These apply to every route in this module, but they are not all mounted the same way, and the
mounting point is where you look when a refusal is unexplained. auth.RateLimitMiddleware and
health.LifecycleMiddleware are r.Use on the root router, in that order.
auth.Middleware is not — it wraps each route individually via auth.WrapWithMiddlewares,
called from this module's own Routes(). So the effective order is rate limit → shutdown check →
authentication → handler. None of these refusals comes from the handlers.
| Status | Code | Cause |
|---|---|---|
401 | common.unauthorized | Five branches of auth.Middleware: no Authorization header, no Bearer prefix, a token that does not parse or has expired, a failed blacklist lookup, or a blacklisted token. All five call Unauthorized401 with no AppError, so all five are indistinguishable from the body |
403 | common.rbac_no_rec_access → No access to the record | rbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code |
403 | license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed, license_m.license_key_missing | The licence branch. module_not_licensed means the tenant's licence does not cover this module: its routes exist in the binary and are refused. license_key_missing is not a tenant problem — it means licenseService was nil, so the server came up without a usable COREBANQ_LICENSE_KEY and refuses every route until it is restarted. A fifth code, license_m.license_service_unavailable, is matched by the middleware but is written to the licence-error context by no code path, so it never reaches a client |
429 | rate_limits_m.exceeded, rate_limits_m.global_exceeded, rate_limits_m.failed_to_increment_ip_limit | auth.RateLimitMiddleware, mounted only when the AppConfig flag rate_limits.rate_limits_switcher is true — with it off the middleware is absent from the chain and this status is unreachable. It runs first of everything here. Not common.too_many_requests — that key is only the fallback stamped when the helper is called with no AppError. Two free-text strings, rate limit exceeded and Global rate limit exceeded, also occur as the code and are not i18n keys |
503 | {"overall_status": "unhealthy", …} — not the envelope | Graceful shutdown. health.LifecycleMiddleware is r.Use on the root router, so it precedes authentication and every handler — but not the rate limiter, which is mounted before it |
503 | auth_m.internal_server_error → Internal server error | The auth cache is unhealthy. ensureCacheAvailable runs before the blacklist lookup, so an unreachable Redis/valkey refuses every authenticated request. The code is the auth package's own MsgInternalServerError, not errs.MsgInternalServerError (common.server_error) |
There is a third source of 403, and it is the one most callers actually hit. The two rows
above are pre-handler refusals; the module's own record-level check raises uploads.unauthorized
when a correctly licensed caller with the endpoint grant lacks the record permission on the upload,
or on the customer it hangs off. All three share the status, so a client has to branch on code.
The 503 is two different bodies, and a client has to branch on the shape rather than assume
the envelope. While the server is draining, health.LifecycleMiddleware writes a bare map —
{"overall_status": "unhealthy", "message": "Service is shutting down", "timestamp": "…"} —
with no status, code, class or retryable field on it at all, and whose message is a fixed
English string, not an i18n key. The auth-cache 503 is the envelope, with the status
overridden; its code is auth_m.internal_server_error. Both it and common.server_error render
the same English message, so the code is the only thing that tells a cache outage apart from a
generic fault.
All four refusals are declared on all 14 operations in uploads.openapi.json — Unauthorized401,
Forbidden403, RateLimited429 and ServiceUnavailable503 under components/responses.
Replace an existing `draft` transfer created by `POST /v2/transfers/create`. Full-replace semantics: the request body is the complete draft state, so fields omitted from the body are cleared. Only `source` (and its owning `customer_id`) is required; every other field is optional. Contract validation (product, destination, amount, FX/quote, product pre-flight) is deferred to finalize, so a partial draft can be edited repeatedly while it stays in `draft` status. Returns 400 with `status` when the transfer is no longer a draft. A draft saved without a `destination` may have one filled in later: that is not a re-assignment, so the beneficiary customer is recorded and the matching RBAC grants are issued as part of the edit. **Unsupported (contract limitation):** an edit may not move a draft between owning customers. From/To may be re-selected within the same customer(s), but changing the originator, or changing an existing beneficiary to a different customer — including clearing one by omitting `destination` — is rejected with 400 because the `ori_customer_id`/`ben_customer_id` columns and the RBAC record grants issued at creation are not reconciled here. Errors: `reason: originator_customer_change_unsupported` (field `customer_id`) or `reason: beneficiary_customer_change_unsupported` (field `destination`). Returns 409 (`transfers_m.draft_finalize_in_progress`, `reason=draft_finalize_in_progress`) when a finalize for this draft is already in flight. Finalize claims the draft under a row lock before reading its payload, so an edit that arrives afterwards is rejected rather than silently discarded when the draft is superseded. Retry the edit if the finalize fails and releases its claim.
Upload a file for a specific customer. Requires update permission on the customer and create permission on uploads. `customer_id` in the path is authoritative; `document_type` must be a key from the documents map in the uploads config.