FinzBooksDevelopers

Attachments

Generic file blobs stapled to any document — PO scans, signed contracts, proof-of-delivery photos, bank statements. The same table holds all of them; the polymorphic (entity_type, entity_id) pair points at the document the file belongs to.

Bytes live in S3 / MinIO under an orgs/<org_id>/attachments/<entity_type>/<entity_id>/<uuid>-<filename> key. The row stores filename, MIME type, size, and the bucket key. Downloads always go through a 15-minute presigned URL — no public read access on the bucket.

Resource shape

{
  "attachment_id":         "att_8c3f1a9b40b14e3aaf2d7e91a76c1f02",
  "entity_type":           "INVOICE",
  "entity_id":             "inv_abc",
  "filename":              "purchase-order.pdf",
  "content_type":          "application/pdf",
  "size_bytes":            127334,
  "description":           "Customer PO scan",
  "download_url":          "https://s3.ap-south-1.amazonaws.com/aibooks-prod/orgs/org_8f9a1c2d/attachments/INVOICE/inv_abc/att_8c3f1a9b-purchase-order.pdf?X-Amz-Signature=...&X-Amz-Expires=900",
  "uploaded_by_user_id":   "usr_xyz",
  "created_time":          "2026-05-12T08:30:00Z"
}

download_urlis regenerated on every list / get call, so it's always 15 minutes fresh. Don't cache it.

Common request headers

Authorization:   Bearer aibooks_pat_XXXX...
X-AIBooks-Org:   org_8f9a1c2d
POST/attachmentsUpload a fileAIBooks.attachments.CREATE

Multipart upload. The file form field carries the bytes; entity_type + entity_id tell the server which document to attach to. Files are uploaded to S3 with their original filename preserved (path components are stripped for safety).

Form fieldTypeNotes
filefileThe file to upload. 25MB hard cap.
entity_typestringOne of INVOICE / BILL / ESTIMATE / CREDIT_NOTE / DEBIT_NOTE / SALES_ORDER / PURCHASE_ORDER / PAYMENT / EXPENSE / CONTACT / JOURNAL.
entity_idstringID of the resource to attach to.
descriptionstringOptional caption shown in listings.

Sample request

POST /api/public/v1/attachments HTTP/1.1
Host: api.aibooks.in
Authorization: Bearer aibooks_pat_3p9q0v1w2x3y4z5a6b7c8d9e
X-AIBooks-Org: org_8f9a1c2d
Content-Type: multipart/form-data; boundary=----aibooks_boundary_x9k2

------aibooks_boundary_x9k2
Content-Disposition: form-data; name="entity_type"

INVOICE
------aibooks_boundary_x9k2
Content-Disposition: form-data; name="entity_id"

inv_8c3f1a9b
------aibooks_boundary_x9k2
Content-Disposition: form-data; name="description"

Customer PO scan
------aibooks_boundary_x9k2
Content-Disposition: form-data; name="file"; filename="purchase-order.pdf"
Content-Type: application/pdf

%PDF-1.7 ...[127,334 bytes]... %%EOF
------aibooks_boundary_x9k2--

Sample response

HTTP/1.1 201 Created
Content-Type: application/json

{
  "code":    0,
  "message": "The file has been attached.",
  "attachment": {
    "attachment_id":       "att_8c3f1a9b40b14e3aaf2d7e91a76c1f02",
    "entity_type":         "INVOICE",
    "entity_id":           "inv_8c3f1a9b",
    "filename":            "purchase-order.pdf",
    "content_type":        "application/pdf",
    "size_bytes":          127334,
    "description":         "Customer PO scan",
    "download_url":        "https://s3.ap-south-1.amazonaws.com/aibooks-prod/orgs/org_8f9a1c2d/attachments/INVOICE/inv_8c3f1a9b/att_8c3f1a9b-purchase-order.pdf?X-Amz-Signature=...&X-Amz-Expires=900",
    "uploaded_by_user_id": "usr_xyz",
    "created_time":        "2026-05-12T08:30:00Z"
  }
}

The endpoint does NOT validate that entity_id actually exists — partner apps that want strict referential integrity should validate before upload. This is deliberate: the upload can succeed in parallel with the document creation, and tying them together later is cheap. Orphaned attachments (entity_id never created) can be cleaned up with a daily sweep job.

GET/attachmentsList attachmentsAIBooks.attachments.READ

Returns attachments for the org, newest first. Filter by ?entity_type=INVOICE&entity_id=inv_abc to scope to a single document. Hard cap of 200 per call (use the per_page param to lower it).

ParamWhereNotes
entity_typequeryOptional. Scope to one resource type.
entity_idqueryOptional. Scope to one specific document.
per_pagequery1 – 200. Default 50.

Sample request

GET /api/public/v1/attachments?entity_type=INVOICE&entity_id=inv_8c3f1a9b&per_page=10 HTTP/1.1
Host: api.aibooks.in
Authorization: Bearer aibooks_pat_3p9q0v1w2x3y4z5a6b7c8d9e
X-AIBooks-Org: org_8f9a1c2d

Sample response

HTTP/1.1 200 OK
Content-Type: application/json

{
  "code":    0,
  "message": "success",
  "attachments": [
    {
      "attachment_id":       "att_8c3f1a9b40b14e3aaf2d7e91a76c1f02",
      "entity_type":         "INVOICE",
      "entity_id":           "inv_8c3f1a9b",
      "filename":            "purchase-order.pdf",
      "content_type":        "application/pdf",
      "size_bytes":          127334,
      "description":         "Customer PO scan",
      "download_url":        "https://s3.ap-south-1.amazonaws.com/aibooks-prod/orgs/org_8f9a1c2d/attachments/INVOICE/inv_8c3f1a9b/att_8c3f1a9b-purchase-order.pdf?X-Amz-Signature=...&X-Amz-Expires=900",
      "uploaded_by_user_id": "usr_xyz",
      "created_time":        "2026-05-12T08:30:00Z"
    },
    {
      "attachment_id":       "att_5e7f9a1b2c3d4e5f6a7b8c9d0e1f2a3b",
      "entity_type":         "INVOICE",
      "entity_id":           "inv_8c3f1a9b",
      "filename":            "delivery-proof.jpg",
      "content_type":        "image/jpeg",
      "size_bytes":          384012,
      "description":         "Signed POD from courier",
      "download_url":        "https://s3.ap-south-1.amazonaws.com/aibooks-prod/orgs/org_8f9a1c2d/attachments/INVOICE/inv_8c3f1a9b/att_5e7f9a1b-delivery-proof.jpg?X-Amz-Signature=...&X-Amz-Expires=900",
      "uploaded_by_user_id": "usr_xyz",
      "created_time":        "2026-05-11T16:14:22Z"
    }
  ],
  "page_context": {
    "page":     1,
    "per_page": 10,
    "has_more_page": false
  }
}
GET/attachments/{attachment_id}Get one attachmentAIBooks.attachments.READ

Returns the same shape as the list row, with a freshly signed download_url. Useful when an old URL has expired — call GET again to mint a new one.

Sample request

GET /api/public/v1/attachments/att_8c3f1a9b40b14e3aaf2d7e91a76c1f02 HTTP/1.1
Host: api.aibooks.in
Authorization: Bearer aibooks_pat_3p9q0v1w2x3y4z5a6b7c8d9e
X-AIBooks-Org: org_8f9a1c2d

Sample response

HTTP/1.1 200 OK
Content-Type: application/json

{
  "code":    0,
  "message": "success",
  "attachment": {
    "attachment_id":       "att_8c3f1a9b40b14e3aaf2d7e91a76c1f02",
    "entity_type":         "INVOICE",
    "entity_id":           "inv_8c3f1a9b",
    "filename":            "purchase-order.pdf",
    "content_type":        "application/pdf",
    "size_bytes":          127334,
    "description":         "Customer PO scan",
    "download_url":        "https://s3.ap-south-1.amazonaws.com/aibooks-prod/orgs/org_8f9a1c2d/attachments/INVOICE/inv_8c3f1a9b/att_8c3f1a9b-purchase-order.pdf?X-Amz-Signature=...&X-Amz-Expires=900",
    "uploaded_by_user_id": "usr_xyz",
    "created_time":        "2026-05-12T08:30:00Z"
  }
}
DELETE/attachments/{attachment_id}Delete an attachmentAIBooks.attachments.DELETE

Removes the S3 object and the metadata row. Best-effort delete on S3: if the object delete fails (already missing, transient error), the metadata row is still removed so the attachment doesn't reappear in listings. Orphan S3 objects from this path don't cost much and are easier to garbage-collect than dangling rows.

Sample request

DELETE /api/public/v1/attachments/att_8c3f1a9b40b14e3aaf2d7e91a76c1f02 HTTP/1.1
Host: api.aibooks.in
Authorization: Bearer aibooks_pat_3p9q0v1w2x3y4z5a6b7c8d9e
X-AIBooks-Org: org_8f9a1c2d

Sample response

HTTP/1.1 200 OK
Content-Type: application/json

{
  "code":    0,
  "message": "The attachment has been deleted."
}

Errors

HTTP/1.1 413 Payload Too Large
Content-Type: application/json

{
  "code":    "validation.invalid_value",
  "message": "File exceeds 25 MB limit.",
  "field":   "file",
  "details": { "max_bytes": 26214400, "received_bytes": 38291104 }
}
CodeHTTPWhen
validation.invalid_value400Unknown entity_type, empty file.
validation.invalid_value413File exceeds the 25MB cap.
storage.upload_failed502S3 / MinIO rejected the PUT. Retry-safe.
not_found.resource404Attachment ID not in this org (for GET / DELETE).

Why polymorphic, not per-resource?

Every invoice, bill, contact, journal entry can have files attached. A dedicated invoice_attachments, bill_attachments table per resource means 11 nearly-identical tables to maintain, and 11 different endpoints to call. One attachments table with (entity_type, entity_id) is one table, one set of endpoints, and a single index for all listings — the trade-off is no foreign-key enforcement, which we accept in exchange for the surface-area win.