The Write Model
Six shared rules every write-bearing capability follows, so conflict handling and offline sync are implemented once — not per feature.
Capabilities like reactions andplaylists mutate user-owned state on your service. Rather than each feature inventing its own semantics, every write-bearing capability — standard or vendor — follows these six rules.
1. Identity required
Writes execute against a provider-side user account, so the request must carry a credential you can map to one: aVCP-User-Token or apaired session token. Requests without one get 401 unauthorized. You decide which of your credentials grant which<capability>.write scopes.
2. Ownership enforced
Users can only read and mutate their own objects. IDs of user-owned objects must be authorized against the requesting identity on every call — object ids are not secrets, and guessing one must yield 403 forbidden or404 not_found, never data. This is the classic IDOR trap; don't fall in it.
3. Idempotent verbs
- State-setting uses
PUT— safe to retry blindly. - Deletes are idempotent:
DELETEof an already-gone object →204. - Creates use
POSTwith anIdempotency-Keyheader — an opaque client-generated key you should remember for at least 24 hours, replaying the original response on retry instead of creating a duplicate.
# Retrying a create with the SAME Idempotency-Key replays the original
# response instead of creating a duplicate:
curl -X POST "https://api.example.com/vcp/v1/playlists" \
-H "VCP-Service-Token: vcps_a81f42..." \
-H "Authorization: Bearer vcpa_71be02..." \
-H "Idempotency-Key: 9c4a7ee2-51b0-4de1-8a3f-2f4c01a4b9d7" \
-H "Content-Type: application/json" \
-d '{ "title": "Rewatch queue" }'
# Run it twice: both return 201 with the SAME playlist object.4. Optimistic concurrency
Mutable user objects carry a version (opaque string, also served asETag). Structural mutations — reorder, metadata patch — sendIf-Match: {version}; a stale version gets412 precondition_failed, and the client refetches then reapplies. Simple value-sets (a reaction, watch progress) are last-write-wins and skip versioning entirely.
import requests
API = "https://api.example.com/vcp/v1"
HEADERS = {
"VCP-Service-Token": "vcps_a81f42...",
"Authorization": "Bearer vcpa_71be02...",
}
def reorder(playlist_id, intended_order):
"""Apply the user's intended order, refetching once on a version conflict."""
for _ in range(2):
playlist = requests.get(f"{API}/playlists/{playlist_id}",
headers=HEADERS).json()
resp = requests.put(f"{API}/playlists/{playlist_id}/order",
headers={**HEADERS, "If-Match": playlist["version"]},
json={"item_ids": intended_order})
if resp.status_code != 412:
resp.raise_for_status()
return resp.json()
raise RuntimeError("playlist kept changing underneath us")require "rest-client"
require "json"
API = "https://api.example.com/vcp/v1"
HEADERS = {
"VCP-Service-Token" => "vcps_a81f42...",
"Authorization" => "Bearer vcpa_71be02...",
content_type: :json
}
# Apply the user's intended order, refetching once on a version conflict.
def reorder(playlist_id, intended_order)
2.times do
playlist = JSON.parse(
RestClient.get("#{API}/playlists/#{playlist_id}", HEADERS).body
)
begin
resp = RestClient.put("#{API}/playlists/#{playlist_id}/order",
{ item_ids: intended_order }.to_json,
HEADERS.merge("If-Match" => playlist["version"]))
return JSON.parse(resp.body)
rescue RestClient::PreconditionFailed
next # stale version - refetch and retry
end
end
raise "playlist kept changing underneath us"
endconst API = 'https://api.example.com/vcp/v1'
const HEADERS = {
'VCP-Service-Token': 'vcps_a81f42...',
'Authorization': 'Bearer vcpa_71be02...',
'Content-Type': 'application/json',
}
// Apply the user's intended order, refetching once on a version conflict.
async function reorder(playlist_id, intended_order) {
for (let attempt = 0; attempt < 2; attempt++) {
const playlist = await (await fetch(API + '/playlists/' + playlist_id, {
headers: HEADERS,
})).json()
const resp = await fetch(API + '/playlists/' + playlist_id + '/order', {
method: 'PUT',
headers: { ...HEADERS, 'If-Match': playlist.version },
body: JSON.stringify({ item_ids: intended_order }),
})
if (resp.status !== 412) {
if (!resp.ok) throw new Error('reorder failed: ' + resp.status)
return resp.json()
}
}
throw new Error('playlist kept changing underneath us')
}5. Offline queue
Clients may queue writes while offline and replay them in order on reconnect — idempotency is what makes that replay safe. Queued writes that then fail with401/403 are dropped with a user-visible notice, not retried forever.
6. Sync visibility
If you advertise the changes capability, your feed must emit change entries for user-owned objects of every write capability you support, using the same <object>.<created|updated|deleted> pattern (e.g. playlist.updated), scoped to the requesting identity — that's how a user's other devices converge.