Docs Documentation
The Engagement API
Post photos, videos and notes into a live engagement from your own system — a photo booth, a kiosk, an app — like a guest would from their phone, and hand out a take-home link.
Guests post to an engagement from their phones. The Engagement API lets your own system do the same: a photo booth, a kiosk, a tablet at the entrance, an app or a script. What it posts goes through the engagement’s moderation like any guest’s post and lands on the same sticky notes and polaroid walls.
For every photo and video you also get a take-home link: a page where the person sees their shot and can share or save it on their phone. Show it as a QR code.
Before you start
Set up an engagement
The engagement must take what you want to post — polaroids, videos or notes. See Set up an engagement.
Create an API key
As the workspace owner, open Settings → Integrations and create a key with Live engagements (
engagements:write). A key that only lists engagements needs Live engagements (read) (engagements:read). The key is shown once: store it on the device or server that calls the API.Send it with every call
Authorization: Bearer wfi.…. A key belongs to one workspace and sees only that workspace’s engagements.
Find the engagement
GET /api/v1/engagements lists them, newest first. Each has its id, whether it takes posts now (state: open, paused or closed), what it accepts, its rooms, whether it asks for consent, and its limits. Note the id: every other call names it. GET /api/v1/engagements/{id} reads one again, for example to check it is open before you start.
curl https://www.wunderframe.com/api/v1/engagements \
-H "Authorization: Bearer $WUNDERFRAME_KEY"{
"id": "eng_Q2mK7vB3xYcZp4dW1",
"name": "Gala night",
"key": "a6ptt2gre6vu",
"url": "https://www.wunderframe.com/e/a6ptt2gre6vu",
"state": "open",
"open": true,
"moderation": "approval",
"accepts": { "note": true, "question": false, "photo": true, "video": true },
"rooms": [
{ "key": "up82k8", "label": "Lobby", "url": "https://www.wunderframe.com/e/a6ptt2gre6vu/up82k8" }
],
"consent": { "required": true, "text": "Your post may be shown on the screens at this event." },
"limits": { "photo_max_bytes": 8388608, "video_max_bytes": 62914560, "video_max_s": 15,
"photo_caption_max_chars": 60, "name_max_chars": 40, "client_label_max_chars": 40 }
}Post a photo in three calls
Ask for an upload URL
POST /api/v1/engagements/{id}/uploadswith the file’skind(photoorvideo), its type and its exact size in bytes. You get anupload_idand a signedupload_urlfor this one file.Send the file
PUT the file’s bytes to
upload_url, with theheadersthe answer named. Do it straight away: the URL is short-lived and works once.Post it
POST /api/v1/engagements/{id}/postswithkind, theupload_id, and optionally acaption, the person’sname, aroomand your ownclient_label(for example Booth 1). The answer has thepost_id, itsstatusand thetake_home_url.
# 1. An upload URL for one JPEG
curl -X POST https://www.wunderframe.com/api/v1/engagements/$ENGAGEMENT/uploads \
-H "Authorization: Bearer $WUNDERFRAME_KEY" \
-H "Content-Type: application/json" \
-d "{\"kind\":\"photo\",\"mime\":\"image/jpeg\",\"bytes\":$(wc -c < shot.jpg)}"
# → { "upload_id": "u3f9…", "upload_url": "https://…", "method": "PUT", "headers": { "Content-Type": "image/jpeg" } }
# 2. The file itself
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @shot.jpg
# 3. The post
curl -X POST https://www.wunderframe.com/api/v1/engagements/$ENGAGEMENT/posts \
-H "Authorization: Bearer $WUNDERFRAME_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: booth1-shot-0042" \
-d '{"kind":"photo","upload_id":"u3f9…","caption":"Gala night 2026","client_label":"Booth 1","consent":true}'{
"post_id": "pst_7vB3xYcZp4dW1qR8",
"engagement_id": "eng_Q2mK7vB3xYcZp4dW1",
"kind": "photo",
"status": "pending",
"video_status": null,
"room": null,
"client_label": "Booth 1",
"share_ready": false,
"take_home_url": "https://www.wunderframe.com/e/a6ptt2gre6vu/p/pst_7vB3xYcZp4dW1qR8?t=…",
"take_home_expires_at": 1790236800000,
"created_at": 1790150400000
}Photos are JPEG, PNG or WebP up to 8 MB. The file is checked when you post: it must really be the type you named and no larger than the size you asked for. Send a picture of a sensible size — about 2000 pixels on the long side is plenty for the walls.
Videos, notes and questions
- A video — the same three calls with
kind: "video": MP4, MOV or WebM up to 60 MB, when the engagement takes videos (accepts.video). It is made small for the walls first:video_statusgoes fromprocessingtoready, and only then does it reach the walls. Keep it withinlimits.video_max_s. - A note or a question — one call, no upload:
{"kind": "note", "text": "Congratulations!"}. They have no take-home link.
The take-home QR code
take_home_url opens a page for this one photo or video, in the engagement’s guest page design, with Share and Save buttons — the same as a guest gets on their phone. The link is signed, works for 24 hours after the shot, and opens nothing else. Polling returns the same link every time.
The shared file is a copy made on our side. On the free plan it carries a small Wunderframe mark in a corner; the walls always show the photo without it. Making the copy takes a moment, so poll GET /api/v1/engagements/{id}/posts/{post_id} every 2–3 seconds until share_ready is true, then show the QR code. Someone who scans earlier simply sees the page wait for a few seconds.
curl https://www.wunderframe.com/api/v1/engagements/$ENGAGEMENT/posts/pst_7vB3xYcZp4dW1qR8 \
-H "Authorization: Bearer $WUNDERFRAME_KEY"
# → { "status": "approved", "share_ready": true, "take_home_url": "https://www.wunderframe.com/e/…/p/…?t=…", … }A post the operator rejects or takes down loses its link: take_home_url becomes null, and a link already scanned says the photo is no longer available. Only the key that made a post can read it.
What decides where a post goes
- Moderation — as the engagement says. With open the post reaches the walls at once (
status: "approved"); with approval it waits for the operator (pending); with auto-check a caption that trips the text check waits. See Running it live. - Open, paused, closed — the same as for guests. While paused, posts are taken and wait off the live walls; while closed, the API answers
423 closed. Checkstatebefore a guest steps up, so your booth can say so. - Rooms — send a room’s
keyfromroomsasroom, and walls set to that room show the post. A room that is not the engagement’s is refused. - Consent — when
consent.requiredis true, showconsent.textat your booth and send"consent": truewith the post once the person agreed. Without it the post is refused. The engagement’s retention applies to API posts as to any other. See Privacy and consent.
Retries and limits
Send an Idempotency-Key header with every post — any unique string, such as a UUID per shot. If the answer gets lost and you send the same request again with the same key, you get the first post back (200 and Idempotent-Replayed: true) instead of a second one. Keys are remembered for 24 hours; the same key with a different body is a 409.
Each key may ask for 300 upload URLs, make 150 posts and read 3000 times per 10 minutes, and each engagement takes up to 600 posts per 10 minutes from everyone together. Past a limit the API answers 429 with Retry-After in seconds.
Errors
Every error is {"error": {"code": "…", "message": "…"}}; for a bad field, details.field names it.
400 invalid— a missing or wrong field, an unknown room, a caption that is too long, missing consent, an upload that did not arrive.401 unauthorized— no key, or a revoked one.403 forbidden— the key lacks the scope, or the engagement does not take that kind (videos switched off).404 not_found— no such engagement or post for this key.409 conflict— anIdempotency-Keyreused for a different request, or one still running.413 too_largeand415 unsupported— the file is too big, or not a type the engagement takes.423 closed— the engagement takes no posts right now.429 rate_limited— waitRetry-Afterseconds.
Every endpoint, field and answer is in the API reference.
Example: a photo booth
A booth takes the picture itself, then does the whole flow: upload, post, wait for the copy, show the QR code. Run it on the booth’s computer, with the key and the engagement id in its settings. The same few lines in Node.js and in Python:
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
const API = 'https://www.wunderframe.com/api/v1';
const KEY = process.env.WUNDERFRAME_KEY;
const ENGAGEMENT = process.env.WUNDERFRAME_ENGAGEMENT;
async function call(method, path, body, headers = {}) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json', ...headers },
body: body && JSON.stringify(body)
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
return data;
}
/** Post one shot and return the link for the QR code. */
export async function postShot(file, { name, caption } = {}) {
const bytes = await readFile(file);
const up = await call('POST', `/engagements/${ENGAGEMENT}/uploads`, {
kind: 'photo', mime: 'image/jpeg', bytes: bytes.length
});
const put = await fetch(up.upload_url, { method: up.method, headers: up.headers, body: bytes });
if (!put.ok) throw new Error(`upload ${put.status}`);
let post = await call('POST', `/engagements/${ENGAGEMENT}/posts`, {
kind: 'photo', upload_id: up.upload_id, caption, name,
client_label: 'Booth 1', consent: true // the guest agreed on the booth's screen
}, { 'Idempotency-Key': randomUUID() });
// Wait up to a minute for the take-home copy, then show the QR code.
for (let i = 0; i < 30 && !post.share_ready && post.take_home_url; i++) {
await new Promise((r) => setTimeout(r, 2000));
post = await call('GET', `/engagements/${ENGAGEMENT}/posts/${post.post_id}`);
}
return post.take_home_url;
}
console.log(await postShot('shot.jpg', { caption: 'Gala night 2026' }));import os, time, uuid, requests
API = "https://www.wunderframe.com/api/v1"
ENGAGEMENT = os.environ["WUNDERFRAME_ENGAGEMENT"]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['WUNDERFRAME_KEY']}"
def call(method, path, **kw):
res = session.request(method, API + path, timeout=30, **kw)
if not res.ok:
err = res.json()["error"]
raise RuntimeError(f"{res.status_code} {err['code']}: {err['message']}")
return res.json()
def post_shot(path, caption=None, name=None):
data = open(path, "rb").read()
up = call("POST", f"/engagements/{ENGAGEMENT}/uploads",
json={"kind": "photo", "mime": "image/jpeg", "bytes": len(data)})
requests.request(up["method"], up["upload_url"], headers=up["headers"], data=data, timeout=60).raise_for_status()
post = call("POST", f"/engagements/{ENGAGEMENT}/posts",
json={"kind": "photo", "upload_id": up["upload_id"], "caption": caption, "name": name,
"client_label": "Booth 1", "consent": True},
headers={"Idempotency-Key": str(uuid.uuid4())})
# Wait up to a minute for the take-home copy, then show the QR code.
for _ in range(30):
if post["share_ready"] or not post["take_home_url"]:
break
time.sleep(2)
post = call("GET", f"/engagements/{ENGAGEMENT}/posts/{post['post_id']}")
return post["take_home_url"]
print(post_shot("shot.jpg", caption="Gala night 2026"))