Attachment API returns 200 but attachment doesn't appear

I’m getting the body {"errors":null,"success":null} when I run my PHP code. When I run the test in, I see Postman that a successful response is more verbose. The logs don’t offer any clues:

2023-09-26T06:59:17.403857369Z: WEB 	▶  PUT 200 /api/v1/tasks/11091/attachments 2.8333ms - GuzzleHttp/7

Any insight would be much appreciated. Thank you!

Can you share your code? To me this looks like you don’t pass the upload file properly.

I threw a lot of spaghetti at the wall, but this is what finally gave me a 200:

        // Sets up the auth header and no others. I've been using this for all my requests, so I'm fairly confident there's no problem here.
        $headers = VikunjaHelper::getHeaders(); 
        $client = new GuzzleHttp\Client(['base_uri' => $url]);
        $body = Psr7\Utils::tryFopen($this->localPath, 'r');

        $options = [
            'headers' => $headers,
            'multipart' => [
                    'name' => $this->fileName,
                    'contents' => $body,
                    'headers' => $headers

        $r = $client->request('PUT', $url, $options);

Let me know if you see anything wrong. I see the headers are sent twice, but when I populated only one, I got an error, the details of which I don’t remember.

Here’s the relevant Guzzle documentation: Request Options — Guzzle Documentation

When I tried using a different type of Guzzle request having added the Content-Type header myself, I got an error in the Vikunja logs about Boundary not being set. I tried tossing in any old value just to see what would happen, but I got a different error (again, don’t remember the details). The way I read the Guzzle documentation, ‘multipart’ is the only way Guzzle will add the Content-Type header itself and thus deal with the Boundary value correctly.

I would love to hear your thoughts.

Many thanks!

Glad you figured it out!

Multipart Form is the correct way to do this. See also the docs: Vikunja API documentation
I can imagine Guzzle is smart enough to send the header only once if you provide it multiple times.

I’m sorry, I was unclear. I didn’t figure out. That’s the code that gives me the 200 but doesn’t result in the attachment uploading.

Still working on this. I mentioned that it works in Postman. The PHP code generated by Postman generates results in the same {"errors":null,"success":null} but no attachment made.

This is the generated PHP:

        $curl = curl_init();

        curl_setopt_array($curl, array(
            CURLOPT_URL => 'http://[IP]/api/v1/tasks/11605/attachments',
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_ENCODING => '',
            CURLOPT_MAXREDIRS => 10,
            CURLOPT_TIMEOUT => 0,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_POSTFIELDS => array('files' => fopen('/home/kj/scripts/attachments/1696993909.jpg', 'r')),
            CURLOPT_HTTPHEADER => array(
                'Authorization: Bearer [token]'

Any idea what am I/is Postman doing wrong?


A curl request like this works fine:

curl 'http://localhost:3456/api/v1/tasks/1140/attachments' \
  -X 'PUT' \
  -H "Authorization: Bearer $BEARER" \
  -F "files=@/path/to/file"

According to chatgpt, this is the equivalent php code:

// Replace these variables with your actual values
$apiEndpoint = 'http://localhost:3456/api/v1/tasks/1140/attachments';
$bearerToken = 'YOUR_BEARER_TOKEN';
$filePath = '/path/to/file';

// Initialize cURL session
$ch = curl_init($apiEndpoint);

// Set cURL options
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $bearerToken,
curl_setopt($ch, CURLOPT_POSTFIELDS, [
    'files' => new CURLFile($filePath),

// Execute the cURL request
$response = curl_exec($ch);

// Check for cURL errors and handle the response as needed
if ($response === false) {
    echo 'cURL error: ' . curl_error($ch);
} else {
    echo 'Response: ' . $response;

// Close cURL session

Does thatt work?

It worked, thank you! :heart: