CorebanqCorebanq Developer Docs
Uploads

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):

CodePurpose
bylawsBylaws with beneficiary information
articles_of_associationArticles of association
licenseLicense documents
customer_agreementCustomer agreement
corporate_structureCorporate structure
declaration_of_shareholdersDeclaration of shareholders
statutesStatutes or founding deed
tariffsTariff documents
account_statementsAccount statements
otherOther / uncategorized
avatarAvatar (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 approval
  • approved: Document approved
  • attached: No approval needed

Signature Status

  • pending: Awaiting signatures
  • signed: Document signed
  • not_required: No signature needed

Avatar Types

  • customer: Customer avatars
  • user: 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, 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 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 a 500, not a 400 and not a silent drop. The shared parser rejects it, but this route builds its total query before ApplySearchAndSort — and ApplySearchAndSort is the only place that raises query_m.invalid_search_field to a 400. So the same typo that is a 400 on 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 empty 200 before the search is parsed. The split is on the field name, not on whether the field exists: search.foo-bar=1 fails the identifier pattern first and is a plain 400 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 .operator suffix, two search._text.* operators at once, a valued operator with no operand, a non-UUID value on a UUID field — keeps its 400.
  • An unknown sort field is also a 500. ParseSortFields builds query_m.invalid_sort_field with a 400, and ApplySearchAndSort rewraps it as items_m.item_failed_to_list without a code, which HandleAppErrorWithCode answers as 500.
  • limit=-1 is not the skip-data-fetching sentinel here. The parser preserves -1, but the service reads the value through query.GetLimit, which turns any non-positive value into 10, and then fetches the page. A client asking for a count-only response gets ten records.
  • Omitting sort leaves the page unordered. ApplySearchAndSort sorts only when the parameter is present, and the service adds no ordering of its own — so with no sort there is no ORDER BY at all and pagination over the result may repeat or skip rows. The shared created_at DESC default applies only to an empty sort=, 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 codeGo constant (errs)Description
uploads.failed_to_serializeErrMsgUploadFailedToSerializeFailed to serialize upload data
uploads.duplicate_entityErrMsgUploadDuplicateEntityDuplicate upload exists
uploads.unauthorizedErrMsgUploadUnauthorizedInsufficient record or scoped access (upload/customer)
uploads.database_errorErrMsgUploadDatabaseErrorUpload database operation failed
uploads.record_not_foundErrMsgUploadRecordNotFoundUpload record not found
uploads.invalid_file_extensionErrMsgUploadInvalidFileExtensionFile extension or content type not allowed
uploads.invalid_document_typeErrMsgInvalidDocumentTypedocument_type not in uploads.documents config
uploads.invalid_upload_inputErrMsgInvalidUploadInputInvalid or empty request/patch body
uploads.failed_to_decode_dataErrMsgUploadFailedToDecodeDataFailed to decode base64 file content
uploads.file_too_largeErrMsgUploadFileTooLargeFile exceeds configured max size
uploads.file_too_smallErrMsgUploadFileTooSmallFile below configured min size
uploads.user_not_foundErrMsgUploadUserNotFoundUser not found (upload context)
uploads.invalid_approval_statusErrMsgInvalidApprovalStatusInvalid approval status
uploads.invalid_signature_statusErrMsgInvalidSignatureStatusInvalid signature status
uploads.user_already_signedErrMsgUserAlreadySignedUser has already signed this document
uploads.document_no_signature_countErrMsgNoSignatureCountDocument has no signature count
uploads.upload_not_foundErrMsgUploadNotFoundUpload not found (signature flow)
uploads.approval_status_errorErrMsgApprovalStatusErrorApproval status transition error
uploads.failed_to_fetch_uploadErrMsgFailedToFetchUploadFailed to load upload
uploads.metadata_tag_requiredErrMsgUploadMetadataTagRequiredCustomer file list metadata filter missing tag
uploads.metadata_tag_invalidErrMsgUploadMetadataTagInvalidCustomer file list metadata tag must be a string array
Wire codeGo constant (errs)Description
customers_m.not_foundMsgCustomerNotFoundCustomer not found
customers.principal_not_foundMsgPrincipalNotFoundCustomer principal not found
common.forbiddenMsgForbiddenForbidden (e.g. avatar owner access)
rbac_m.failed_to_grant_accessMsgFailedToGrantAccessFailed 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.

StatusCodeCause
401common.unauthorizedFive 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
403common.rbac_no_rec_access → No access to the recordrbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code
403license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed, license_m.license_key_missingThe 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
429rate_limits_m.exceeded, rate_limits_m.global_exceeded, rate_limits_m.failed_to_increment_ip_limitauth.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 envelopeGraceful 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
503auth_m.internal_server_error → Internal server errorThe 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.

PUTEdit draft transfer (v2)

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.

POSTCreate customer file

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.

On this page