Overview
The Attachments API provides methods to manage file attachments uploaded to messages. Hazel Chat uses a direct-to-R2 upload flow where files are uploaded directly to cloud storage, then marked as complete or failed.
Attachment Upload Flow
Create attachment record (via separate endpoint, not shown here)
Upload file directly to R2 using presigned URL
Call attachment.complete when upload succeeds
Or call attachment.fail if upload fails
Attachments have three possible statuses:
uploading - Initial state, file is being uploaded
complete - Upload succeeded, attachment is ready
failed - Upload failed, attachment is unusable
attachment.delete
Delete an attachment (soft delete). Only the uploader or users with appropriate permissions can delete attachments.
Transaction ID for optimistic updates
Errors:
AttachmentNotFoundError - Attachment doesn’t exist
UnauthorizedError - User lacks permission to delete this attachment
InternalServerError - Server error
await client . rpc ( "attachment.delete" , {
id: "attach_abc123"
})
console . log ( "Attachment deleted" )
attachment.complete
Mark an attachment as complete after successfully uploading to R2. Only the original uploader can mark as complete.
The completed attachment Unique attachment identifier
Organization that owns this attachment
Channel the attachment belongs to
Message the attachment is attached to
Public URL to access the file
User who uploaded the attachment
status
'uploading' | 'complete' | 'failed'
Current upload status
Timestamp when upload was initiated
Deletion timestamp (null if active)
Errors:
AttachmentNotFoundError - Attachment doesn’t exist
UnauthorizedError - User is not the original uploader
InternalServerError - Server error
// 1. Create attachment record (presigned URL obtained separately)
const attachmentId = "attach_abc123"
// 2. Upload file to R2 using presigned URL
await fetch ( presignedUrl , {
method: "PUT" ,
body: fileBlob ,
headers: { "Content-Type" : file . type }
})
// 3. Mark as complete
const result = await client . rpc ( "attachment.complete" , {
id: attachmentId
})
console . log ( "Attachment ready:" , result . fileName )
console . log ( "Status:" , result . status ) // "complete"
attachment.fail
Mark an attachment as failed after an upload error. Only the original uploader can mark as failed.
Optional error message or failure reason
Errors:
AttachmentNotFoundError - Attachment doesn’t exist
UnauthorizedError - User is not the original uploader
InternalServerError - Server error
try {
// Upload file to R2
await fetch ( presignedUrl , {
method: "PUT" ,
body: fileBlob
})
// Mark as complete
await client . rpc ( "attachment.complete" , {
id: attachmentId
})
} catch ( error ) {
// Upload failed, mark attachment as failed
await client . rpc ( "attachment.fail" , {
id: attachmentId ,
reason: error . message
})
console . error ( "Upload failed:" , error )
}
Best Practices
Error Handling
Always handle upload failures gracefully and mark attachments as failed:
async function uploadAttachment ( file : File , attachmentId : string ) {
try {
// Get presigned URL (implementation not shown)
const presignedUrl = await getPresignedUrl ( attachmentId )
// Upload to R2
const response = await fetch ( presignedUrl , {
method: "PUT" ,
body: file
})
if ( ! response . ok ) {
throw new Error ( `Upload failed: ${ response . statusText } ` )
}
// Mark as complete
await client . rpc ( "attachment.complete" , { id: attachmentId })
return { success: true }
} catch ( error ) {
// Mark as failed
await client . rpc ( "attachment.fail" , {
id: attachmentId ,
reason: error . message
})
return { success: false , error }
}
}
Progress Tracking
Track upload progress for better UX:
async function uploadWithProgress (
file : File ,
attachmentId : string ,
onProgress : ( percent : number ) => void
) {
const xhr = new XMLHttpRequest ()
return new Promise (( resolve , reject ) => {
xhr . upload . addEventListener ( "progress" , ( e ) => {
if ( e . lengthComputable ) {
const percent = ( e . loaded / e . total ) * 100
onProgress ( percent )
}
})
xhr . addEventListener ( "load" , async () => {
if ( xhr . status === 200 ) {
await client . rpc ( "attachment.complete" , { id: attachmentId })
resolve ()
} else {
await client . rpc ( "attachment.fail" , {
id: attachmentId ,
reason: `HTTP ${ xhr . status } `
})
reject ( new Error ( `Upload failed: ${ xhr . status } ` ))
}
})
xhr . addEventListener ( "error" , async () => {
await client . rpc ( "attachment.fail" , {
id: attachmentId ,
reason: "Network error"
})
reject ( new Error ( "Upload failed" ))
})
xhr . open ( "PUT" , presignedUrl )
xhr . send ( file )
})
}