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