Astro 7.3.8 actions: call the Sume video API, key stays on the server
Astro actions give a browser form a typed way to start a Sume video job and poll it, without the API key in client code. Zod 4 validation, 402 handling.

Define two Astro actions in src/actions/index.ts: one that submits a Sume video job and one that reads its status. The browser calls them with actions.submitClip() and actions.clipStatus(), Astro validates the input with Zod, and the Sume API key is read from import.meta.env on the server only. I ran this on astro 7.3.8 with the Node adapter, posting to the action routes against a local stand-in for the Sume API: bad input, the submit, and the status read all behaved as described.
astro 7.3.8 was published on 2026-10-08. Its notes include a fix for Astro.cache being undefined on custom 404 and 500 pages and a move of bundling to Vite's Oxc-based tools and Rolldown. A related @astrojs/vercel 11.0.14 on 2026-10-12 fixes on-demand routes failing with a missing native binding, which matters if your action runs on Vercel.
Why use an action instead of a client-side fetch?
Sume authenticates every /v1 call with an API key, as the authentication docs describe, so the key cannot ship in browser code. The Actions guide says an action handler runs on the server, receives validated input, and returns serializable data or throws an ActionError. That makes it a small typed proxy: the page sends a prompt, and only the action holds the secret. Set output: "server" and an adapter, since form-based actions need on-demand rendering.
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({ output: "server", adapter: node({ mode: "standalone" }) });What does the submit action look like?
The client sends a random UUID with each click. The action forwards it as Idempotency-Key, so a double click or a network retry replays the same job and does not start a second paid one. wan-3.0 takes duration from 2 to 30 seconds, so five seconds at 480p is valid. A 402 becomes the PAYMENT_REQUIRED action error so the page can show an out-of-credits message.
import { defineAction, ActionError } from "astro:actions";
import { z } from "astro/zod";
const sume = (path: string, init: RequestInit = {}) =>
fetch(`https://api.sume.com${path}`, {
...init,
headers: {
Authorization: `Bearer ${import.meta.env.SUME_API_KEY}`,
"Content-Type": "application/json",
...init.headers,
},
});
export const server = {
submitClip: defineAction({
input: z.object({ prompt: z.string().min(3).max(500), key: z.uuid() }),
handler: async ({ prompt, key }) => {
const res = await sume("/v1/videos", {
method: "POST",
headers: { "Idempotency-Key": key },
body: JSON.stringify({ model: "wan-3.0", prompt, duration: 5, resolution: "480p" }),
});
if (res.status === 402) throw new ActionError({ code: "PAYMENT_REQUIRED", message: "Out of credits." });
if (!res.ok) throw new ActionError({ code: "BAD_GATEWAY", message: `Sume ${res.status}` });
return { id: (await res.json()).id as string };
},
}),How does the browser learn when the clip is done?
Add the second action to the same server object, and poll it from the page about every 30 seconds, which is the interval the video guide suggests. The regex on id matters: it stops a caller from turning your proxy into a general path to any other Sume route.
clipStatus: defineAction({
input: z.object({ id: z.string().regex(/^job_[A-Za-z0-9]+$/) }),
handler: async ({ id }) => {
const job = await (await sume(`/v1/videos/${id}`)).json();
return { status: job.status as string, url: (job.unsigned_urls?.[0] ?? null) as string | null };
},
}),
};When status is completed, the poll response lists unsigned_urls, and the guide shows the download as GET /v1/videos/{jobId}/content?index=0 with the bearer key. Do that fetch on the server, then serve or store the file yourself, so the key never reaches the page.
| Sume reply | ActionError code | Page behavior |
|---|---|---|
402 insufficient_credits | PAYMENT_REQUIRED | Show an out-of-credits message |
| No call made: Zod rejects the input | isInputError fields from Astro | Highlight the field |
5xx or other non-OK | BAD_GATEWAY | Offer a retry with the same UUID |
What are the limits of this pattern?
An action is a request, not a job runner. Do not wait for a video inside the handler. Return the job id and poll, or use callback_url with a verified webhook for server-side work. Add your own sign-in check in the handler through context.locals, because the action route is public to anyone who can reach your site, and every call can spend credits.
Sources
Related posts
More in Developers
- Bun 1.4.3 EAI_AGAIN vs ETIMEOUT: retry a Sume submit safely
Bun 1.4.3 reports a temporary DNS failure as EAI_AGAIN, not ETIMEOUT. Update your retry check, and keep one Idempotency-Key across every Sume submit retry.
- Bun 1.4.3 fetch stream errors: catch a cut-off Sume video download
Bun 1.4.3 fixes response streams that ended cleanly after a transfer failure. Still check the byte count when you save a finished Sume video to disk.
- Extend a Grok Imagine video on Sume: chain by last frame
xAI's guide lists video extension from the final frame. Sume's Grok row has no extend, so pull a late frame with video-frames and start the next clip from it.
- Claude Code 2.1.296: MCP tool names over 128 characters, and Sume's
Claude Code 2.1.296 leaves out an MCP tool whose name exceeds 128 characters. The longest tool name in Sume's hosted list is 28, so none are near it.
Written by Sume