Upload Step 2 fails with a 200 status

I’m following the documentation and examples to upload a file. (I am not able to use the official JS or ruby libraries, so I am doing it with HTTPS).

Step 1 works, giving me a URL, request_headers, and an id.
When using the URL and request_headers from Step 1 to perform Step 2, instead of getting an error or the expected 202 status, I get this:

{status: 200, headers: %{"content-length" => ["0"], "date" => ["Fri, 31 Jan 2025 07:10:24 GMT"], "etag" => ["removed"], "server" => ["AmazonS3"], "x-amz-id-2" => ["removed"], "x-amz-request-id" => ["removed"], "x-amz-server-side-encryption" => ["AES256"], "x-amz-version-id" => ["removed"]}, body: "", trailers: %{}, private: %{}}

The body is empty, the content-length is zero, and the status is 200.

The request_headers from Step 1 are empty, so the only headers I’m sending to step 2 is {ā€œContent-Typeā€, ā€œimage/jpegā€} with the image binary

What am I doing wrong?

Here is the request i’m making for step 2:

PUT step1.url HTTP/1.1
Content-Type: image/png
<FILE_BINARY_CONTENT>

curl gives me the same response:

curl -v -X PUT "url from step1" \
     -H "Content-Type: image/jpeg" \
     --data-binary "@/path/to/image.jpeg"

Hello @Chris_Richards and welcome to the community!

Make sure you’re sending the exact URL (including all query parameters) and all headers returned by step 1. Even if request_headers is empty, you still need to include any query string (e.g. ?x-amz-acl=...) in the PUT request. S3 often responds with 200 and an empty body if the upload succeeds. As long as you use the full URL and headers from step 1, you can move on to step 3 once you see that 200.

The response’s content-length and empty body refer to the response from S3, not your upload payload. A 200 with an empty body from S3 is normal for a successful PUT. If you suspect your file isn’t actually being sent, confirm that the query string from step 1 is fully included, you’re setting all the returned headers, and your code is streaming the binary file contents without any transformation (not multipart, no extra JSON wrapping, etc.). Also verify with debug logs or by performing a GET/HEAD on the final S3 URL to confirm the file was uploaded.

1 Like

I verified I’m sending the exact URL and curl says it sent the file.

But if I do curl -X GET URL I get an Access Denied message. My token is for Content Management API and I am able to upload new records without issue. Its just the File Upload that’s doing this.

Step 3, https://site-api.datocms.com/uploads returns 200 with a job and id. But when I try to use that id with https://site-api.datocms.com/job-results/#{job_id} I get a ā€œ404 NOT_FOUNDā€,

I’m not sure what I’m missing, is there an extra permission I need outside of the Content Management API Token that I use for records?

Hey @Chris_Richards,

Can you please log the exact request & response chain from Curl and send it to us at support@datocms.com (or DM us here, or post it publicly after stripping the credentials)? It’s hard to troubleshoot this without seeing the exact payload going over the wire.

I suspect that what’s going on is that the AWS presigned URL from step 1 isn’t getting sent to S3 correctly. That signed URL has nothing to do with your Dato tokens, but is rather a single-use secret generated by S3 (in step 1) to allow you to PUT a file directly to them – entirely outside of Dato. It’s a secret token that you send as part of the URL query string.

Once that file is successfully uploaded to S3, step 3 just ā€œassociatesā€ it with a upload record inside Dato. The 404 not found for a job request means it’s still processing (as documented in step 4), so you could keep polling until it 200s or just ignore it and wait a few seconds.

In a bit more detail…

Please make sure the attributes.url from Step 1’s response is being used, in its entirety, for PUTing the image to S3. It should look something like this:

https://datosrl-images.s3.eu-west-1.amazonaws.com/150894/1738359901-filename.png?x-amz-acl=private&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=SECRET%2F20250131%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Date=20250131T214501Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=SIGNATURESTRING

If you then PUT the image to S3 and get back a 200 with an empty body, that’s totally normal (as Marcelo said).

Then in step 3, you need to send a body like:

{"data":{"type":"upload","attributes":{"path":"/150894/1738359901-screenshot-000977.png"},"relationships":{}}}

Where the path is the data.id from Step 1’s Dato response (not step 2 from AWS)

If that went through right, you should get a 202 Accepted, not a 200.

Then if you poll for the job result, it will 404 until it completes, but you don’t really need to worry about that. The image should just show up in the media area after a few seconds. If it doesn’t, something is failing along the way, but it’s hard to say what exactly without seeing your exact requests & responses.


Also, if you go to the media area in your project and open the browser’s network inspector and then try to upload an image, you can see the exact requests & responses being sent. The media area does it the same way our client does, or the way you would if you were uploading via raw HTTP. If you right-click an entry and copy it as curl, you can re-use that request elsewhere and compare it to what you have now.

In the inspector:

  • Step 1 would be a POST to https://site-api.datocms.com/upload-requests
  • Step 2 would be a PUT to https://datosrl-images.s3.eu-west-1.amazonaws.com/ (or step’s URL)
  • Step 3 would be a POST to https://site-api.datocms.com/uploads
  • (I think we skip step 4 in the media area and instead just refresh the list of uploads)

The issue was because I didn’t have custom_data as a prop in step 3. The code that works is this:

body = %{
  data: %{
    type: "upload",
    attributes: %{
      path: path,
      default_field_metadata: %{
        "en" => %{
          title: title,
          alt: alt,
          custom_data: %{}
        }
      }
    }
  }
}
1 Like

Thanks for updating the thread, @Chris_Richards :slight_smile:

What language is that? Haven’t seen the %{} syntax before.

It’s Elixir, it’s the syntax for an empty object

1 Like

One final note (for anyone coming into this later): We’ve updated the docs for the HTTP upload endpoint: https://www.datocms.com/docs/content-management-api/resources/upload/create?language=http

Specifically, steps 3 and 4 should be much clearer now about what’s happening behind the scenes.