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

# Build an EdgeSpark REST API with Hono and D1

> Build a production-style EdgeSpark REST API with Hono, D1, auth, presigned uploads, secrets, and a deployable server scaffold.

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`:

```typescript server/src/defs/db_schema.ts theme={null}
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:

```bash theme={null}
edgespark db generate --name add_posts
edgespark db migrate
```

## Step 2: Declare a storage bucket

Edit `server/src/defs/storage_schema.ts`:

```typescript server/src/defs/storage_schema.ts theme={null}
import type { BucketDef } from "@sdk/server-types";

export const images: BucketDef<"images"> = {
  bucket_name: "images",
  description: "Post cover images",
};
```

Apply the bucket declaration:

```bash theme={null}
edgespark storage apply
```

## Step 3: Declare and register a secret

First allow the key in `server/src/defs/runtime.ts`:

```typescript server/src/defs/runtime.ts theme={null}
export type VarKey = never;

export type SecretKey =
  | "NOTIFY_WEBHOOK_SECRET";
```

Then register it:

```bash theme={null}
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`:

```typescript server/src/index.ts theme={null}
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

```bash theme={null}
edgespark deploy
```

Test the public endpoint:

```bash theme={null}
curl https://my-app.edgespark.app/api/public/posts
```

<Note>
  New projects currently deploy to one default production environment. Public staging support is coming soon.
</Note>

## Step 6: Upload from the client

Use your frontend to request a presigned upload URL, then upload directly to R2:

```typescript web/src/upload.ts theme={null}
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

<Columns cols={2}>
  <Card title="Use the database" icon="database" href="/guides/database">
    Define schema, generate migrations, and query D1 in the current scaffold.
  </Card>

  <Card title="Use storage" icon="hard-drive" href="/guides/storage">
    Declare buckets, apply them with the CLI, and use presigned upload URLs.
  </Card>
</Columns>
