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.

5 min readSume
All posts

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.

Action error codes used for Sume replies (read 2026-10-13)
Sume replyActionError codePage behavior
402 insufficient_creditsPAYMENT_REQUIREDShow an out-of-credits message
No call made: Zod rejects the inputisInputError fields from AstroHighlight the field
5xx or other non-OKBAD_GATEWAYOffer 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

All Developers posts

Written by Sume