Send a file from your computer

A file on your computer can go in a conversation too, not only a file at a public url. Upload it, get an attachment id, and send that id on either send route. HookChat keeps the file only until Meta has it.

How it works#

  1. POST /v1/attachments/uploads with the conversation, the file name, its content type and its size. HookChat checks the type and size for that conversation's account and hands out a storage url and form fields. The url works for 15 minutes.
  2. Post the file to that url as one multipart form: every entry in fields first, then the file as file. This goes straight to storage, with no API key.
  3. POST /v1/attachments with the upload id. HookChat checks the file's real size and first bytes and answers 202 at once with the upload processing. It hands the file to Meta in the background and deletes its copy.
  4. GET /v1/attachments/uploads/{upload_id} every second or two until status is ready. Most files are ready in a few seconds; a big video can take a minute. The upload then carries attachment.id.
  5. Send it on the reply route or the human-agent route as { "type": "image", "attachment_id": "<id>" } in attachments.

A send carries text or attachments, never both. Up to 10 images go as one message; a video, audio clip or file goes alone. A url and an uploaded file can sit side by side in one send of images.

Upload with an SDK or the CLI#

Each SDK runs every step in one call and returns the attachment. The CLI does the same with attachments upload, and --attachment image=@./photo.jpg on a send uploads first, then sends.

You do not poll: HookChat hands the file to Meta in the background, and each helper waits for you. It reads the upload every second, then every two seconds, for up to two minutes. Change the wait with wait_timeout_ms in TypeScript, wait_timeout (seconds) in Python, WaitTimeout in Go or --wait-timeout in the CLI.

If the wait runs out, the helper stops with a timeout error that names the upload, and the upload goes on: read it later with attachments.get (Attachments.Get in Go, hookchat attachments get in the CLI). A file Meta refuses fails with send_failed, as a refused send does.

TypeScript
import { HookChat } from '@hookchat/node'

const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
const conversation_id = process.env.CONVERSATION_ID!

// A path, a Buffer, or a Blob or File. The name and type come from it.
const a = await client.attachments.upload({ conversation_id, file: './photo.jpg' })
const { platform_message_id } = await client.messages.reply({
  conversation_id,
  attachments: [{ type: a.type, attachment_id: a.id }],
})
console.log('sent', platform_message_id)

To upload without sending, or to keep the id for later, use the upload alone. The CLI prints the attachment's id, type and size; --quiet prints only the id, and --output json prints all of it.

Shell
hookchat attachments upload --conversation "$CONVERSATION_ID" --file ./quote.pdf

The steps with curl#

Without an SDK, make the calls yourself. The script below uploads photo.jpg, waits until Meta has it, and sends it, with jq to read the replies.

Upload and send
# 1. Ask for an upload url. HookChat checks the type and size first.
size=$(wc -c < photo.jpg | tr -d ' ')
upload=$(curl -sS -X POST "$HOOKCHAT_BASE_URL/v1/attachments/uploads" \
  -H "Authorization: Bearer $HOOKCHAT_API_KEY" -H 'Content-Type: application/json' \
  -d "$(jq -n --arg c "$CONVERSATION_ID" --argjson size "$size" \
        '{conversation_id: $c, filename: "photo.jpg", content_type: "image/jpeg", size: $size}')")

# 2. Post the file to that url: every field first, then the file. No API key.
url=$(jq -r .data.upload.url <<<"$upload")
fields=()
while IFS= read -r field; do fields+=(--form-string "$field"); done \
  < <(jq -r '.data.upload.fields | to_entries[] | "\(.key)=\(.value)"' <<<"$upload")
curl -sS --fail -X POST "$url" "${fields[@]}" -F "file=@photo.jpg;type=image/jpeg"

# 3. Finish: HookChat checks the file, answers at once, and hands it to Meta.
upload_id=$(jq -r .data.upload.id <<<"$upload")
curl -sS -X POST "$HOOKCHAT_BASE_URL/v1/attachments" \
  -H "Authorization: Bearer $HOOKCHAT_API_KEY" -H 'Content-Type: application/json' \
  -d "{\"upload_id\": \"$upload_id\"}" | jq -e '.data.upload.status == "processing"'

# 4. Read the upload until Meta has it. A big video can take a minute.
for _ in $(seq 1 120); do
  state=$(curl -sS "$HOOKCHAT_BASE_URL/v1/attachments/uploads/$upload_id" \
    -H "Authorization: Bearer $HOOKCHAT_API_KEY")
  case "$(jq -r .data.upload.status <<<"$state")" in
    ready) break ;;
    failed) jq .data.upload.error <<<"$state"; exit 1 ;;
  esac
  sleep 1
done

# 5. Send it by its id.
curl -sS -X POST "$HOOKCHAT_BASE_URL/v1/messages/reply" \
  -H "Authorization: Bearer $HOOKCHAT_API_KEY" -H 'Content-Type: application/json' \
  -d "$(jq -n --arg c "$CONVERSATION_ID" --arg id "$(jq -r .data.upload.attachment.id <<<"$state")" \
        '{conversation_id: $c, attachments: [{type: "image", attachment_id: $id}]}')"

Step 1 answers with the storage url and the fields to post. Post them exactly as given, all of them, before the file: storage checks a signed policy that names the exact key, the content type and the most bytes it takes.

Step 1: the upload
{
  "ok": true,
  "data": {
    "upload": {
      "id": "upl_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
      "url": "https://uploads.example.com/",
      "fields": {
        "key": "uploads/acme/instagram/17841405309211844/upl_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
        "Content-Type": "image/jpeg",
        "Policy": "eyJleHBpcmF0aW9uIjoi...",
        "X-Amz-Signature": "4b1c..."
      },
      "method": "POST",
      "expires_at": "2026-10-07T03:29:11.000Z",
      "max_size": 8388608
    }
  }
}
Step 2: the form, fields first and the file last
curl -X POST "https://uploads.example.com/" \
  --form-string "key=uploads/acme/instagram/17841405309211844/upl_01J9Z8Y7X6W5V4T3S2R1Q0P9N8" \
  --form-string "Content-Type=image/jpeg" \
  --form-string "Policy=eyJleHBpcmF0aW9uIjoi..." \
  --form-string "X-Amz-Signature=4b1c..." \
  -F "file=@photo.jpg;type=image/jpeg"

Storage answers 204 with no body. Use --form-string for the fields so curl never reads a value as a file name. Then finish the upload. It answers 202 at once: the file passed the checks and is on its way to Meta.

Step 3: the finish, 202
{
  "ok": true,
  "data": {
    "upload": {
      "id": "upl_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
      "status": "processing",
      "conversation_id": "CONV#acme#instagram#17841405309211844#6021573449812077",
      "filename": "photo.jpg",
      "content_type": "image/jpeg",
      "size": 48213,
      "type": "image"
    }
  }
}

Read the upload until status is ready, or failed. Ready, it carries attachment.id: that id is Meta's, and is what a send names as attachment_id. Failed, it carries error with the code and, when Meta refused the file, Meta's reason in detail. An upload stays readable for a day.

Step 4: the upload, ready
{
  "ok": true,
  "data": {
    "upload": {
      "id": "upl_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
      "status": "ready",
      "conversation_id": "CONV#acme#instagram#17841405309211844#6021573449812077",
      "filename": "photo.jpg",
      "content_type": "image/jpeg",
      "size": 48213,
      "type": "image",
      "attachment": { "id": "1234567890123456", "type": "image" }
    }
  }
}

What Meta takes#

HookChat checks these when it hands out the url and again on the bytes that arrive, so a file Meta would refuse is refused early, with a clear code. type is optional on step 1: left out, it follows the content type.

typeInstagramMessenger
imagepng or jpeg, up to 8 MB; gif too on an account linked with Facebook Loginpng, jpeg or gif, up to 8 MB
videomp4, mov, webm, ogg or avi, up to 25 MBThe same, up to 25 MB
audioaac, m4a, wav or mp4, up to 25 MBThe same, up to 25 MB
filePDF only, up to 25 MBAny type but a program, up to 25 MB

What HookChat keeps#

  • The file is kept only until Meta has it. It is deleted right after the hand-off, whether Meta took it or not.
  • Storage deletes anything left behind, such as a file posted but never finished, within a day.
  • An upload finishes once. After a refusal at step 3, or an upload that failed, start again from step 1.
  • A test key works the same way, but nothing reaches Meta: the attachment id starts with mock_att_, and the file is still deleted.

Errors#

codeHTTPMeaning
invalid_request400A field is missing or malformed: the conversation, the file name, the content type, the size, or the upload id.
conversation_not_found404No such conversation, or not one this key can reach.
upload_not_found404No such upload: it expired, was used already, or no file has been posted to it yet. The message says which.
attachment_too_large413The file is over Meta's limit for its kind.
unsupported_media_type415The account does not take this type, or the file's first bytes say it is not what its content type claims.

Meta's answer comes later, on the upload: a failed upload's error.code is send_failed when Meta refused the file (detail carries Meta's reason), internal_error when HookChat could not hand it over, and conversation_not_found when the account was unlinked meanwhile. The SDKs raise each as the same error a request would.

Storage answers step 2 on its own, without the API envelope. The SDKs and the CLI turn its refusals into the same codes: a file too big is attachment_too_large, an expired url or a content type that does not match is invalid_request. See Troubleshooting for each code.

Next#