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/attachmentsUpload a fileAIBooks.attachments.CREATEMultipart 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 field | Type | Notes |
|---|---|---|
file | file | The file to upload. 25MB hard cap. |
entity_type | string | One of INVOICE / BILL / ESTIMATE / CREDIT_NOTE / DEBIT_NOTE / SALES_ORDER / PURCHASE_ORDER / PAYMENT / EXPENSE / CONTACT / JOURNAL. |
entity_id | string | ID of the resource to attach to. |
description | string | Optional 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.
/attachmentsList attachmentsAIBooks.attachments.READReturns 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).
| Param | Where | Notes |
|---|---|---|
entity_type | query | Optional. Scope to one resource type. |
entity_id | query | Optional. Scope to one specific document. |
per_page | query | 1 – 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_8f9a1c2dSample 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
}
}/attachments/{attachment_id}Get one attachmentAIBooks.attachments.READReturns 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_8f9a1c2dSample 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"
}
}/attachments/{attachment_id}Delete an attachmentAIBooks.attachments.DELETERemoves 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_8f9a1c2dSample 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 }
}| Code | HTTP | When |
|---|---|---|
validation.invalid_value | 400 | Unknown entity_type, empty file. |
validation.invalid_value | 413 | File exceeds the 25MB cap. |
storage.upload_failed | 502 | S3 / MinIO rejected the PUT. Retry-safe. |
not_found.resource | 404 | Attachment 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.