Skip to main content

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

  1. Create attachment record (via separate endpoint, not shown here)
  2. Upload file directly to R2 using presigned URL
  3. Call attachment.complete when upload succeeds
  4. 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.
id
AttachmentId
required
Attachment identifier
transactionId
TransactionId
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.
id
AttachmentId
required
Attachment identifier
data
Attachment
The completed attachment
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.
id
AttachmentId
required
Attachment identifier
reason
string
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)
  })
}

Build docs developers (and LLMs) love