API
API reference
All endpoints are under the base below, return JSON, and (except the two public discovery endpoints) require a Bearer key. Server order ids (gift_id) and share slugs are unguessable — a gift another key owns answers 404, never 403, so ids can't be probed.
https://myvideogift.com/api/v1/v1/occasionsPublicThe occasion catalog. Browse before you hold a key; pick a slug to pass as occasion when you create a gift.
{
"occasions": [
{
"slug": "anniversary-video",
"name": "Anniversary Video",
"short_name": "Anniversary",
"recipient_prompt": "Who is this anniversary for?",
"tone": "romantic",
"answer_first": "...",
"photo_ideas": ["...", "..."]
}
]
}Occasion fields
| Field | Type | Description |
|---|---|---|
| slugreq | string | The value to pass as occasion on create. |
| namereq | string | Full occasion name. |
| short_namereq | string | Short label. |
| recipient_promptreq | string | How to ask who the gift is for. |
| tonereq | string | The emotional register of the occasion. |
| gentle | boolean | Present and true only for gentle occasions (memorial, pet tribute). |
| sub_types | string[] | Present only when the occasion offers sub-type framing. |
| answer_first | string | Answer-first lead sentence; present only when the occasion has SEO content. |
| photo_ideas | string[] | Concrete “what to gather” ideas; present only when the occasion has SEO content. |
/v1/samplesPublicPre-rendered sample films — the “what you'll get” gallery that replaces a preview. Optional ?occasion=<slug> narrows the list; an unknown slug returns an empty list, not an error.
{
"caption": "Sample film · Made by our team from a demo photo set — by the same pipeline that makes yours.",
"samples": [
{
"occasion_slug": "fathers-day-video",
"video_url": "https://myvideogift.com/videos/....mp4",
"poster_url": "https://myvideogift.com/videos/....jpg",
"duration_sec": 71,
"description": "..."
}
]
}caption is a required disclosure
caption is a verbatim honesty line: every sample is a team-made demo, never a customer example. If you show samples to an end user, keep it attached.
Sample fields
| Field | Type | Description |
|---|---|---|
| video_urlreq | string | Absolute URL to the sample MP4. |
| poster_urlreq | string | Absolute URL to the poster image. |
| descriptionreq | string | Honest, verbatim description of the sample. |
| occasion_slug | string | The occasion this demo belongs to; omitted for generic demos. |
| duration_sec | number | Runtime in seconds, when known. |
| note | string | A verbatim caveat for a demo that skips a paid-provider step; usually absent. |
/v1/accountKey requiredYour key's identity and prepaid-credit balance. A cheap read for checking funds before you render.
{
"key_id": "ak_1a2b3c4d",
"label": "acme-gifts",
"mode": "live",
"status": "active",
"balance_cents": 5000,
"total_spent_cents": 2500,
"total_deliveries": 1,
"created_at": "2026-07-08T12:00:00.000Z"
}Account fields
| Field | Type | Description |
|---|---|---|
| key_idreq | string | Public key id (e.g. ak_1a2b3c4d) — safe to log. |
| labelreq | string | Your partner label. |
| modereq | "live" | "test" | Billing mode of the key. |
| statusreq | "active" | "revoked" | A revoked key stops authenticating. |
| balance_centsreq | number | Prepaid credit balance, in US cents. |
| total_spent_centsreq | number | Lifetime spend, in US cents. |
| total_deliveriesreq | number | Lifetime render debits. Refunds do not decrement this counter. |
| created_atreq | string | ISO 8601 issuance timestamp. |
/v1/giftsKey requiredCreate and reserve a gift server-side. Free — nothing is charged until you render. Send an optional Idempotency-Key header ([A-Za-z0-9._-], 1–128 chars) to make retries safe.
Request body
| Field | Type | Description |
|---|---|---|
| occasionreq | string | A real occasion slug from GET /v1/occasions. |
| recipient_namereq | string | 1–80 chars. Who the gift is for. |
| relationshipreq | string | 1–80 chars, e.g. “wife”, “grandpa”. |
| memory | string | Up to 500 chars. A memory that feeds the song. |
| story_note | string | Up to 500 chars. Extra story context for the song. |
| feeling_words | string[] | Up to 6 words, each 1–30 chars. Tone words for the song. |
| length_sec | number | 30–180. Target film length; defaults to the standard length. |
| tier | "standard" | "signature" | Defaults to signature. Sets the render recipe and the per-delivery price. |
| style | "gift" | "brand" | "social" | Defaults to gift (unchanged behavior). social biases delivery toward the vertical 9:16 aspect; brand carries the optional brand fields below. Recorded on the order and threaded into the song brief. |
| brand_logo_url | string | Optional (style brand). A public https logo URL, persisted for progressive brand rendering. |
| brand_color | string | Optional (style brand). An accent color as #RRGGBB, persisted for progressive brand rendering. |
{
"gift_id": "b3f1c2a4-9e7d-4c6b-8a1f-2d5e6f7a8b9c",
"status": "reserved",
"tier": "signature",
"style": "gift",
"occasion": "anniversary-video",
"share_slug": "maria-7x2k9q1p4m3n",
"photo_limit": 30
}Idempotent replay
Reusing an Idempotency-Key returns the original gift (with "idempotent_replay": true, status 200) instead of creating a second order. No price appears on create — the agent channel bills per delivery, and the consumer $49 is never surfaced here.
Errors
400 invalid_json— the body was not valid JSON.400 invalid_request— a field failed validation; a truncatedissuesarray names the offending paths.400 invalid_idempotency_key— theIdempotency-Keyheader was malformed.503 store_unavailable— could not persist the order; retry.
/v1/gifts/{gift_id}/photosKey requiredIngest photos into a gift by public URL or inline base64. Free. Up to 10 per request, up to 30 per gift, up to 4 MB each.
Request body
| Field | Type | Description |
|---|---|---|
| photosreq | Photo[] | 1–10 items. Each is either { "url": string } (≤2048 chars) or { "data": base64, "mime": "image/jpeg" | "image/png" | "image/webp" }. |
| subject_photo_index | number | Zero-based index into photos marking the main subject; must be in range. |
{
"photos": [
{ "url": "https://example.com/photos/wedding.jpg" },
{ "url": "https://example.com/photos/trip.jpg" }
],
"subject_photo_index": 0
}{
"photos": [
{ "data": "<base64-encoded-bytes>", "mime": "image/jpeg" }
]
}{
"photo_count": 2,
"photo_ids": ["p01", "p02"],
"subject_photo_id": "p01"
}URL fetches are SSRF-guarded; bytes are verified
Only public http(s) URLs are fetched — private and link-local addresses are blocked. Every image is verified to be a genuine JPEG, PNG, or WebP before storage; a HEIC or a disguised file is rejected with 415.
Errors
404 not_found— the gift does not exist or is not owned by your key.409 order_locked— the gift is past the content stage and no longer accepts photos.400 invalid_request— the body failed validation (e.g.subject_photo_indexout of range).400 photo_limit— the batch would exceed 30 photos; the response includes the currentphoto_count.400 invalid_photo_url/400 invalid_photo_data— a URL could not be fetched (blocked, failed, too many redirects) or the base64 was empty. Partial-failure responses include theindexthat failed and the runningphoto_count.413 payload_too_large— a photo or the request body exceeded 4 MB.415 unsupported_media_type— the bytes were not a supported image.503 store_unavailable— could not store the photos; retry.
/v1/gifts/{gift_id}/clipsKey requiredIngest short video clips by public URL or inline base64. Free. Up to 3 clips per gift, up to 4 MB each here — larger clips (up to 500 MB) use the upload-token endpoint below.
Request body
| Field | Type | Description |
|---|---|---|
| clipsreq | Clip[] | 1–3 items. Each is either { "url": string } (≤2048 chars) or { "data": base64, "mime": "video/mp4" | "video/quicktime" | "video/webm" }. An optional duration_sec (≤600) may accompany either — it's a hint; the render re-probes. |
{
"clips": [
{ "url": "https://example.com/clips/first-dance.mp4", "duration_sec": 42 }
]
}{
"clips": [
{ "data": "<base64-encoded-bytes>", "mime": "video/mp4" }
]
}{
"clip_count": 1,
"clip_ids": ["c01"]
}Containers are sniffed; dimensions are re-probed
Only public http(s) URLs are fetched (SSRF-guarded, ≤4 MB). Every clip's bytes are verified to be a genuine MP4, MOV, or WebM before storage — a disguised file is rejected with 415. Dimensions and exact duration are re-probed server-side during the render, so duration_sec is an optional hint, never a requirement.
Errors
404 not_found— the gift does not exist or is not owned by your key.409 order_locked— the gift is past the content stage and no longer accepts clips.400 clip_limit— the batch would exceed 3 clips; the response includes the currentclip_count.400 invalid_clip_url/400 invalid_clip_data— a URL could not be fetched (blocked, failed, too many redirects) or the base64 was empty. Partial-failure responses include theindexthat failed and the runningclip_count.413 payload_too_large— a clip or the request body exceeded 4 MB; use the upload-token endpoint for larger clips.415 unsupported_media_type— the bytes were not a supported video container.503 store_unavailable— could not store the clips; retry.
/v1/gifts/{gift_id}/clips/{clip_id}Key requiredGet a scoped upload token for a large video clip (up to 500 MB). Free. Drive it with @vercel/blob client upload() so the bytes stream straight to storage, never through the API.
For clips over 4 MB
Point @vercel/blob/client's upload() at this URL as its handleUploadUrl, targeting a slot clip_id (c01–c03), and pass your key in headers. The token is scoped to exactly that slot; the clip meta is written automatically once the bytes land. Optional dims travel in clientPayload ({ duration_sec, width, height }) — all re-probed at render. Poll GET /v1/gifts/{id} (clip_count) to see which slots are taken.
import { upload } from "@vercel/blob/client";
await upload("orders/b3f1c2a4-9e7d-4c6b-8a1f-2d5e6f7a8b9c/clips/c01.mp4", file, {
access: "private",
contentType: "video/mp4",
handleUploadUrl: "https://myvideogift.com/api/v1/gifts/b3f1c2a4-9e7d-4c6b-8a1f-2d5e6f7a8b9c/clips/c01",
headers: { Authorization: "Bearer mvg_live_..." },
clientPayload: JSON.stringify({ duration_sec: 120 }),
multipart: true,
});Errors
404 not_found— the gift does not exist or is not owned by your key.409 order_locked— the gift is past the content stage and no longer accepts clips.400 invalid_clip_id/400 clip_limit— the slot must bec01–c03.400 invalid_pathname— the upload target did not match the requested clip slot.
/v1/gifts/{gift_id}/renderKey requiredPaidThe one paid call. Debits the tier's wholesale price from your prepaid balance and starts the server render. Returns 202 immediately.
Request body
| Field | Type | Description |
|---|---|---|
| consentreq | true | Must be the literal true. Attests you hold the rights to these photos and the recipient's depiction, and that the gift is not for minors-abuse, impersonation, or harassment. Recorded for audit. |
{
"status": "rendering",
"tier": "signature",
"charged_cents": 2500,
"balance_cents": 2500,
"poll_after_sec": 60
}Render is idempotent
While a charge is live, repeating the call returns 202 with "idempotent_replay": true — it never double-charges or double-renders. If that render later fails or is held, the charge auto-refunds and the gift re-opens for a fresh render.
Errors
400 consent_required—consent: truewas missing; the message repeats the attestation.403 test_key_cannot_render— amvg_test_key was used to render; test keys are a free sandbox and can't render. Use a live key.402 insufficient_credits— balance below the tier price; the response includesbalance_centsandprice_cents. Top up, then retry.404 not_found— the gift does not exist or is not owned by your key.409 already_delivered— the film is already rendered; read the URLs from status.409 not_ready— no reservation or zero photos; the response includesphoto_count.502 trigger_failed— the render could not be started after the debit; the charge is auto-refunded.503 unavailable— the render backend is not configured or a transient failure occurred.
/v1/gifts/{gift_id}Key requiredPoll a gift's status and, once delivered, its film and gift URLs. Free. This poll is also where a held/failed render auto-refunds.
{
"gift_id": "b3f1c2a4-9e7d-4c6b-8a1f-2d5e6f7a8b9c",
"status": "reserved",
"tier": "signature",
"occasion": "anniversary-video",
"recipient_name": "Maria",
"share_slug": "maria-7x2k9q1p4m3n",
"photo_count": 2,
"clip_count": 1,
"created_at": "2026-07-08T12:00:00.000Z",
"fulfillment": { "state": "running", "stage": "render" },
"charged_cents": 2500
}{
"gift_id": "b3f1c2a4-9e7d-4c6b-8a1f-2d5e6f7a8b9c",
"status": "delivered-paid",
"tier": "signature",
"occasion": "anniversary-video",
"recipient_name": "Maria",
"share_slug": "maria-7x2k9q1p4m3n",
"photo_count": 2,
"clip_count": 1,
"created_at": "2026-07-08T12:00:00.000Z",
"fulfillment": { "state": "succeeded", "stage": "deliver" },
"charged_cents": 2500,
"film_url": {
"16x9": "https://myvideogift.com/api/gift/maria-7x2k9q1p4m3n/film?aspect=16x9",
"9x16": "https://myvideogift.com/api/gift/maria-7x2k9q1p4m3n/film?aspect=9x16"
},
"gift_url": "https://myvideogift.com/gift/maria-7x2k9q1p4m3n"
}Status fields
| Field | Type | Description |
|---|---|---|
| gift_idreq | string | The server order id. |
| statusreq | string | Order status. delivered-paid means the film is ready and the delivery URLs are present. |
| tierreq | "standard" | "signature" | The render tier. |
| occasionreq | string | The occasion slug. |
| recipient_namereq | string | Who the gift is for. |
| share_slugreq | string | The unguessable slug the delivery URLs are keyed by. |
| photo_countreq | number | Photos stored on the gift. |
| clip_countreq | number | Video clips stored on the gift (0–3). |
| created_atreq | string | ISO 8601 create timestamp. |
| fulfillmentreq | object | null | The coarse render run: { state: "running" | "held" | "succeeded" | "failed", stage?: "prep" | "gate" | "assets" | "render" | "verify" | "deliver" }. Error detail is deliberately withheld. |
| charged_cents | number | Present once a render has been charged. |
| refunded_cents | number | Present once a held/failed render has been auto-refunded. |
| status_detail | string | held_refunded or failed_refunded when a run was auto-refunded. |
| billing | "external" | Present only for externally-billed (pay.sh) gifts. |
| film_url | object | Present only when delivered-paid: { "16x9": url, "9x16": url } — the full-HD MP4 in both aspects. |
| gift_url | string | Present only when delivered-paid: the hosted gift page. |
Pay per delivery, not per attempt
If a render is held (needs more/better photos) or failed, polling status auto-refunds the debit and re-opens the gift. You are only charged for a delivered film. See Errors for the refund model.
Last updated 2026-07-11.