Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.edgespark.dev/llms.txt

Use this file to discover all available pages before exploring further.

This tutorial builds a posts API end-to-end: creating posts, uploading cover images, and processing a signed webhook. It uses the current EdgeSpark scaffold and CLI workflow. What you’ll build:
  • POST /api/posts to create a post
  • GET /api/public/posts to list published posts
  • GET /api/posts/:id to read one post
  • POST /api/upload-url to issue a presigned R2 upload URL
  • POST /api/webhooks/notify to accept a signed webhook

Step 1: Define your schema

Edit server/src/defs/db_schema.ts:
server/src/defs/db_schema.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const posts = sqliteTable("posts", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  content: text("content"),
  coverImagePath: text("cover_image_path"),
  authorId: text("author_id").notNull(),
  published: integer("published").notNull().default(0),
  createdAt: text("created_at").notNull().default("(datetime('now'))"),
});
Generate and apply the migration:
edgespark db generate --name add_posts
edgespark db migrate

Step 2: Declare a storage bucket

Edit server/src/defs/storage_schema.ts:
server/src/defs/storage_schema.ts
import type { BucketDef } from "@sdk/server-types";

export const images: BucketDef<"images"> = {
  bucket_name: "images",
  description: "Post cover images",
};
Apply the bucket declaration:
edgespark storage apply

Step 3: Declare and register a secret

First allow the key in server/src/defs/runtime.ts:
server/src/defs/runtime.ts
export type VarKey = never;

export type SecretKey =
  | "NOTIFY_WEBHOOK_SECRET";
Then register it:
edgespark secret set NOTIFY_WEBHOOK_SECRET
Follow the secure project URL printed by the CLI and enter the value in the browser.

Step 4: Write the server routes

Replace server/src/index.ts:
server/src/index.ts
import { ctx, db, secret, storage } from "edgespark";
import { auth } from "edgespark/http";
import { desc, eq } from "drizzle-orm";
import { Hono } from "hono";
import { buckets, posts } from "@defs";

const app = new Hono()
  .get("/api/public/posts", async (c) => {
    const rows = await db
      .select({
        id: posts.id,
        title: posts.title,
        authorId: posts.authorId,
        createdAt: posts.createdAt,
      })
      .from(posts)
      .where(eq(posts.published, 1))
      .orderBy(desc(posts.createdAt))
      .limit(20);

    return c.json(rows);
  })
  .get("/api/posts/:id", async (c) => {
    const id = Number(c.req.param("id"));
    if (Number.isNaN(id)) return c.json({ error: "Invalid post ID" }, 400);

    const [post] = await db.select().from(posts).where(eq(posts.id, id));
    if (!post) return c.json({ error: "Not found" }, 404);

    return c.json(post);
  })
  .post("/api/posts", async (c) => {
    const body = await c.req.json<{
      title?: string;
      content?: string;
      coverImagePath?: string;
    }>();

    if (!body.title?.trim()) {
      return c.json({ error: "title is required" }, 422);
    }

    const [post] = await db
      .insert(posts)
      .values({
        title: body.title.trim(),
        content: body.content ?? null,
        coverImagePath: body.coverImagePath ?? null,
        authorId: auth.user.id,
      })
      .returning();

    return c.json(post, 201);
  })
  .post("/api/upload-url", async (c) => {
    const body = await c.req.json<{ filename?: string; contentType?: string }>();

    if (!body.filename) {
      return c.json({ error: "filename is required" }, 422);
    }

    const key = `images/${auth.user.id}/${Date.now()}-${body.filename}`;
    const { uploadUrl, requiredHeaders } = await storage
      .from(buckets.images)
      .createPresignedPutUrl(key, 3600, {
        contentType: body.contentType,
      });

    return c.json({ uploadUrl, requiredHeaders, key });
  })
  .post("/api/webhooks/notify", async (c) => {
    const signature = c.req.header("x-webhook-signature") ?? "";
    const rawBody = await c.req.text();
    const signingSecret = secret.get("NOTIFY_WEBHOOK_SECRET") ?? "";

    const encoder = new TextEncoder();
    const cryptoKey = await crypto.subtle.importKey(
      "raw",
      encoder.encode(signingSecret),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["verify"]
    );

    const valid = await crypto.subtle.verify(
      "HMAC",
      cryptoKey,
      hexToBytes(signature),
      encoder.encode(rawBody)
    );

    if (!valid) return c.json({ error: "Invalid signature" }, 401);

    const event = JSON.parse(rawBody) as { type: string; postId: number };
    ctx.runInBackground(handleWebhookEvent(event));

    return c.json({ received: true });
  });

export default app;

function hexToBytes(hex: string): Uint8Array {
  const bytes = new Uint8Array(hex.length / 2);

  for (let i = 0; i < hex.length; i += 2) {
    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
  }

  return bytes;
}

async function handleWebhookEvent(event: { type: string; postId: number }) {
  if (event.type === "post.publish") {
    await db.update(posts).set({ published: 1 }).where(eq(posts.id, event.postId));
  }
}

Step 5: Deploy your project

edgespark deploy
Test the public endpoint:
curl https://my-app.edgespark.app/api/public/posts
New projects currently deploy to one default production environment. Public staging support is coming soon.

Step 6: Upload from the client

Use your frontend to request a presigned upload URL, then upload directly to R2:
web/src/upload.ts
export async function uploadImage(file: File): Promise<string> {
  const { uploadUrl, requiredHeaders, key } = await fetch("/api/upload-url", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  }).then((r) => r.json() as Promise<{
    uploadUrl: string;
    requiredHeaders: Record<string, string>;
    key: string;
  }>);

  await fetch(uploadUrl, {
    method: "PUT",
    headers: requiredHeaders,
    body: file,
  });

  return key;
}

See also

Use the database

Define schema, generate migrations, and query D1 in the current scaffold.

Use storage

Declare buckets, apply them with the CLI, and use presigned upload URLs.
Last modified on April 9, 2026