The Engagement API

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

  1. Set up an engagement

    The engagement must take what you want to post — polaroids, videos or notes. See Set up an engagement.

  2. 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.

  3. 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.

List the engagements
curl https://www.wunderframe.com/api/v1/engagements \
  -H "Authorization: Bearer $WUNDERFRAME_KEY"
One engagement
{
  "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

  1. Ask for an upload URL

    POST /api/v1/engagements/{id}/uploads with the file’s kind (photo or video), its type and its exact size in bytes. You get an upload_id and a signed upload_url for this one file.

  2. Send the file

    PUT the file’s bytes to upload_url, with the headers the answer named. Do it straight away: the URL is short-lived and works once.

  3. Post it

    POST /api/v1/engagements/{id}/posts with kind, the upload_id, and optionally a caption, the person’s name, a room and your own client_label (for example Booth 1). The answer has the post_id, its status and the take_home_url.

upload → PUT → post
# 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}'
The post
{
  "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_status goes from processing to ready, and only then does it reach the walls. Keep it within limits.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.

Wait for the copy
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. Check state before a guest steps up, so your booth can say so.
  • Rooms — send a room’s key from rooms as room, and walls set to that room show the post. A room that is not the engagement’s is refused.
  • Consent — when consent.required is true, show consent.text at your booth and send "consent": true with 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 — an Idempotency-Key reused for a different request, or one still running.
  • 413 too_large and 415 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 — wait Retry-After seconds.

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:

booth.mjs (Node.js 18+)
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' }));
booth.py (Python 3, requests)
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"))