Sume asset complete 409s: checksum, content type, wrong state
POST /v1/assets/{id}/complete checks state, expiry, content type, size and checksum in a fixed order, and deletes the object when it rejects one.

POST /v1/assets/{id}/complete answers 409 with asset_checksum_mismatch when the checksum does not match the one you declared at upload-url, with asset_content_type_mismatch when the stored object's content type differs from the requested one, and with asset_not_pending_upload or asset_not_upload when the asset is in the wrong state. A rejected upload also has its object deleted, so you start over with a new upload URL. These routes are implemented but hidden from the public OpenAPI document, so treat them as a working surface rather than a documented contract; for generation inputs Sume's docs still prefer public HTTPS media URLs.
A sibling post covers the 413, size, incomplete and expired cases; this one covers the rest. Everything is from the route and package code, read 2026-10-11.
Where does the checksum come from?
Only from POST /v1/assets/upload-url. The optional checksum_sha256 there must be 64 hex characters, and when you send it the returned upload.headers include x-amz-meta-sha256 next to content-type and content-length. All three are signed, so your PUT must send them as returned.
At completion a body checksum is only compared, never adopted. The route stores the checksum declared at upload-url, so naming one for the first time in the complete call does not give you verification. Declare it up front.
In what order does complete check things?
The route first rejects an asset that is not a direct upload (asset_not_upload). A ready asset returns 200 again, so repeating completion is idempotent. Any other status except pending_upload gets asset_not_pending_upload. An expired window gets asset_upload_expired, and a missing object gets asset_upload_incomplete.
Then the stored object is compared with the request. Content types are compared by essence, so a ; charset= parameter does not cause a mismatch. A size the storage cannot report, a size different from the one declared or from the complete body, and a stored size over the asset's limit are rejected, then the two checksum comparisons run: body against declared, and expected against the object's stored checksum.
| Code | Meaning | Object deleted |
|---|---|---|
| asset_not_upload | Asset was registered from a URL | No |
| asset_not_pending_upload | Status is not pending_upload or ready | No |
| asset_upload_expired | URL window passed | Yes |
| asset_upload_incomplete | No object at the key | No |
| asset_content_type_mismatch | Stored type differs from requested | Yes |
| asset_size_mismatch | Stored size differs from declared | Yes |
| asset_checksum_mismatch | Checksum differs | Yes |
What does a correct upload look like?
Compute the digest, declare size and checksum, PUT with the returned headers, then complete. If any 409 above comes back with the object deleted, request a fresh upload URL rather than retrying complete.
One more 400 is worth knowing: if you send a media_type other than file, it must agree with the content_type, or upload-url answers invalid_request with 'media_type does not match content_type'. Use file as the media type to skip that check.
import hashlib
import os
import requests
BASE = "https://api.sume.com/v1/assets"
H = {"Authorization": f"Bearer {os.environ['SUME_API_KEY']}"}
def upload(path, content_type):
with open(path, "rb") as f:
body = f.read()
r = requests.post(f"{BASE}/upload-url", headers=H, timeout=30, json={
"content_type": content_type,
"size_bytes": len(body),
"checksum_sha256": hashlib.sha256(body).hexdigest(),
})
r.raise_for_status()
up = r.json()["data"]["upload"]
requests.put(up["url"], data=body, headers=up["headers"],
timeout=300).raise_for_status()
done = requests.post(f"{BASE}/{up['asset_id']}/complete",
headers=H, json={}, timeout=30)
done.raise_for_status()
return done.json()["data"]["asset"]Sources
Related posts
More in Developers
- Sume asset download URL: 300 seconds and the 409 cases
GET /v1/assets/{id}/download-url returns a signed GET link that expires in 300 seconds by default, and 409s for registered URLs or assets not yet ready.
- An uploaded Sume asset you never complete: what happens
A pending_upload asset has a 15-minute URL by default. After the window and a one-hour grace, a reaper deletes the object and marks the row failed.
- Sume generation_capacity_exhausted: the 503, job reason and flag
Sume reports provider capacity three ways: HTTP 503 provider_capacity_exceeded, a job reason generation_capacity_exhausted, and a sync flag. All three retry.
- Sume job failed artifact_too_large: shrink the output, don't rerun
A Sume job failing with artifact_too_large or artifact_upload_rejected made its file but could not store it. Make the file smaller; rerunning changes nothing.
Written by Sume