AI & ML
You Can Upload but Not Edit: YouTube Data API Scopes and publishAt Scheduled Publishing
ACS Developer DEV Community
1 views
Automating YouTube uploads with scheduled publishing produced three failures that never raised an error: youtube.upload cannot call videos.update, a token can silently belong to the wrong channel, and publishAt is ignored unless privacyStatus is private.
What I wanted
Export one short video a day and publish it at a fixed time the next day. I automated the part where "once the exported file is in place, everything runs through to scheduled publishing" with the YouTube Data API v3.
Done by hand it is five steps: open Studio, pick the video, paste in the title and description, swap the thumbnail, and enter the scheduled publish time. On the API side it looks like you just pass status.publishAt to videos.insert and you are done. In practice I tripped twice before that and once after. All of them were the kind where no error appears, but you end up in a state other than the one you intended.
Trip 1: the youtube.upload scope only lets you upload
I had already written the operations runbook: "if you get the title wrong, videos.delete and re-upload with the same publishAt."
Then I actually tried to fix the title of an uploaded video and got this:
HTTP Error 403: Forbidden (reason: insufficientPermissions)
videos.insert works with the same token. Only videos.update fails. The cause was the scopes attached to the token.
# before re-issuing
SCOPES = [
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.readonly",
"https://www.googleapis.com/auth/yt-analytics.readonly",
]
youtube.upload is, as the name says, the scope for uploading: videos.insert and thumbnails.set work. But videos.update and videos.delete do not. Adding the read-only youtube.readonly does not add any write permission. That combination of three produces a token that "can post, but can never touch what it posted".
To include writes you need the full scope https://www.googleapis.com/auth/youtube.
SCOPES = [
"https://www.googleapis.com/auth/youtube", # videos.update / videos.delete
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.readonly",
"https://www.googleapis.com/auth/yt-analytics.readonly",
]
What you have to watch out for is that scopes do not grow onto an existing refresh token later. Scopes are frozen at what was approved on the consent screen, so once you edit SCOPES you have to run the OAuth flow from the start and re-issue (with prompt=consent to force re-consent). Until the token file is overwritten, the 403 will not go away no matter how correct the constant in your code is.
The operational lesson was the bigger one: if you write a runbook on the premise that "you can just fix mistakes through the API", the whole runbook collapses when your scopes are insufficient. I ended up rewriting three steps into "re-upload with the same publishAt and delete the old one by hand."
Trip 2: which channel does that token belong to?
If you run multiple channels, you can grab the wrong token file and upload a video to a completely different channel. And since the upload itself succeeds, no error tells you.
Add one check before posting.
me = api_get("https://www.googleapis.com/youtube/v3/channels"
"?part=snippet&mine=true", H)
ch = me["items"][0]
print(ch["id"], ch["snippet"]["title"]) # UCxxxxxxxxxxxxxxxxxxxxxx <channel name>
I made it standard to run this before executing. It is one request, and absurdly cheap compared to the cost of recovering from the accident (if you do not notice before publication, it goes public on the wrong channel).
Implementation: resumable upload and publishAt
The main flow is a resumable upload: send the metadata first, receive a Location, and PUT the body there in chunks.
meta = {
"snippet": {
"title": title, "description": desc, "tags": tags,
"categoryId": "22",
"defaultLanguage": "ja", "defaultAudioLanguage": "ja",
},
"status": {
"privacyStatus": "private", # required together with publishAt
"publishAt": "2026-09-11T10:45:00Z", # UTC
"selfDeclaredMadeForKids": False,
"license": "youtube", "embeddable": True,
},
}
size = VIDEO.stat().st_size
req = urllib.request.Request(
"https://www.googleapis.com/upload/youtube/v3/videos"
"?uploadType=resumable&part=snippet,status",
data=json.dumps(meta).encode(), method="POST",
headers={**H, "Content-Type": "application/json; charset=UTF-8",
"X-Upload-Content-Length": str(size),
"X-Upload-Content-Type": "video/mp4"})
with urllib.request.urlopen(req, timeout=60) as r:
loc = r.headers["Location"]
The value of privacyStatus is what matters here. publishAt has no effect unless it is paired with privacyStatus: "private". Pass publishAt while leaving it public and it is not scheduled — it simply publishes. No error is returned.
On the body-sending side, the only thing to watch is how you handle 308.
CH = 32 * 1024 * 1024
sent, vid = 0, None
with open(VIDEO, "rb") as f:
while sent < size:
chunk = f.read(CH)
end = sent + len(chunk) - 1
rq = urllib.request.Request(
loc, data=chunk, method="PUT",
headers={**H, "Content-Type": "video/mp4",
"Content-Range": f"bytes {sent}-{end}/{size}"})
try:
with urllib.request.urlopen(rq, timeout=600) as r:
if r.status in (200, 201):
vid = json.loads(r.read())["id"]
except urllib.error.HTTPError as e:
if e.code != 308: # 308 = continue. Not an error.
print("ERR", e.code, e.read()[:300])
sys.exit(1)
sent = end + 1
urllib raises 308 as an exception, so catch HTTPError and swallow only 308. Only the response to the final chunk carries the id.
The thumbnail goes to thumbnails.set as a separate request. The export side emits PNG, so there is a conversion step that pins it to JPEG (if Content-Type and the actual contents disagree it is rejected, so do not leave it to the file extension).
thumb = D / "thumbnail_upload.jpg"
if not thumb.exists() and (D / "thumbnail.png").exists():
from PIL import Image
Image.open(D / "thumbnail.png").convert("RGB").save(
thumb, "JPEG", quality=92, optimize=True)
Trip 3: killing off "I thought it was scheduled"
This was the most effective countermeasure. A 200 from videos.insert only guarantees that a video was created. Whether the publishAt you passed actually took is a separate matter — if the privacyStatus does not line up, it quietly finishes without ever being scheduled.
Read it back immediately after upload and compare.
st = api_get("https://www.googleapis.com/youtube/v3/videos"
f"?part=status,contentDetails&id={vid}", H)["items"][0]
publish_at = st["status"].get("publishAt")
ok = (st["status"]["privacyStatus"] == "private"
and publish_at is not None
and _iso(publish_at) == _iso(requested))
# ... after writing the result out as JSON
if not ok:
sys.exit("ERR: failed to verify privacyStatus/publishAt")
Normalize before comparing. The publishAt the API returns is sometimes 2026-09-11T10:45:00Z and sometimes in +00:00 notation, so a plain string comparison fails on values that should pass.
def _iso(s: str):
from datetime import datetime, timezone
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
After adding this I scheduled two videos and measured verified: true for both (privacyStatus=private and publishAt matching the requested value). The verification result is written straight into the delivery folder.
{
"video_id": "xxxxxxxxxxx",
"privacy": "private",
"publishAt": "2026-09-11T10:45:00Z",
"publishAt_requested": "2026-09-11T10:45:00Z",
"verified": true,
"duration": "PT26S"
}
Stopping double uploads
Since the script runs automatically, running it twice against the same folder lines up two copies of the same video. I used that upload_result.json directly as the idempotency key.
result_path = D / "upload_result.json"
if result_path.exists() and not a.force:
prev = json.loads(result_path.read_text(encoding="utf-8"))
print(f"Already uploaded: {prev.get('video_id')}. Use --force to re-run.")
return
Add --force only when you actually want to re-run. The API has no way to tell whether two uploads are "the same video", so the caller has to own that.
Summary
youtube.upload is upload-only. videos.update / videos.delete need the full youtube scope
After adding a scope, re-issue the OAuth token. Scopes do not grow onto an existing token
Write runbooks that assume "if it breaks, fix it through the API" only after checking your scopes
publishAt is ignored unless paired with privacyStatus: "private" (and no error is raised)
A 308 in a resumable upload is not an error
After posting, read back with videos.list(part=status) and normalize times to UTC before comparing
This is an area with that many "failures that raise no error", so judging by the state you read back, rather than by the response of an API that returned success, turned out to be the cheapest approach in the end.
I publish further verification records and related tools on ACS Developer.
Originally published in Japanese on Zenn: https://zenn.dev/acs_developer/articles/youtube-data-api-upload-scope-publish-at
Read original: https://dev.to/acs_developer/you-can-upload-but-not-edit-youtube-data-api-scopes-and-publishat-scheduled-publishing-4b4h
← Previous
Beyond Fintech: Why Developers Should Pay Attention to Terra Industries’ $52M Bet on Autonomous Systems
Next →
Delayed Gratification and Valent Blocks
Related
The Ownership Gap: Why AI Workflow Failures Sit Unfixed for Weeks
AI & ML
0
DEV Community
AI CURMUDGEON: AI is a backhoe
AI & ML
1
DEV Community
The Three Parallel Workstreams: How Design, Build, and Test Start on Day One Without Colliding
AI & ML
4
Dev.to (EN Zone)
Agentic RAG vs Traditional RAG in .NET (2026) — When Each Wins, Semantic Kernel Code, Production Metrics
AI & ML
4
Dev.to (EN Zone)
Comments0
No comments yet — be the first