Contents Print API (2.1.0)

Download OpenAPI specification:

Minimal photo printing API with Firebase Auth + R2 Direct Upload + Stripe Payment.

Architecture (Minimal)

  • Frontend: Next.js + React Native
  • Backend: Cloudflare Workers + Hono (stateless)
  • Database: Cloudflare D1 (non-PII, idempotency via UNIQUE constraints)
  • Storage: Cloudflare R2 (presigned URL direct PUT)
  • Authentication: Firebase Authentication
  • Payment: Stripe Unified Payment
  • Multi-brand: neko, tokinoe, dog

Design Principles

  • YAGNI: Only 9 endpoints for ~1000 orders/month
  • Cost: $0-$2/month (Workers + D1 + R2 + Stripe)
  • Idempotency: D1 UNIQUE constraints (no Queues/Durable Objects, no client-side Idempotency-Key)
  • Security: Cloudflare Rate Limiting Rules (WAF-level, no Workers KV counters)
  • PII: NEVER stored in D1 (Stripe only)
  • Photo Verification: R2 upload completion verified before checkout

Rate Limiting Strategy

Implementation: Cloudflare Rate Limiting Rules (WAF-level)

Why Cloudflare Rules (NOT Workers KV):

  • ✅ Configured via Cloudflare Dashboard → Security → WAF → Rate Limiting Rules
  • ✅ No code complexity (automatic enforcement before Workers execution)
  • ✅ No KV storage costs
  • ✅ DDoS protection built-in
  • ❌ Avoid Workers-based counters (code complexity, KV costs, race conditions)

Recommended Limits:

  • /api/auth/*: 10 req/min per IP
  • /api/photos/*: 20 req/min per IP
  • /api/stripe/checkout/*: 5 req/min per IP
  • /api/stripe/webhook: No limit (Stripe IP whitelist only)
  • /api/me/*: 30 req/min per IP

Response: HTTP 429 with Retry-After header

D1 Database Schema (Optimized)

-- Users table (Firebase UID + Stripe Customer ID only)
CREATE TABLE users (
  firebase_uid TEXT PRIMARY KEY,
  stripe_customer_id TEXT UNIQUE,
  brand_access TEXT, -- JSON: {"neko":true,"tokinoe":false,"dog":false}
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

-- Orders table (no PII, Stripe IDs only)
CREATE TABLE orders (
  order_id TEXT PRIMARY KEY,
  firebase_uid TEXT NOT NULL,
  stripe_payment_intent_id TEXT UNIQUE NOT NULL,
  stripe_checkout_session_id TEXT UNIQUE,
  brand TEXT NOT NULL,
  status TEXT DEFAULT 'pending',
  total_amount INTEGER NOT NULL,
  item_count INTEGER NOT NULL,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  FOREIGN KEY (firebase_uid) REFERENCES users(firebase_uid)
);

-- Order items (R2 keys only, no photo URLs)
CREATE TABLE order_items (
  id TEXT PRIMARY KEY,
  order_id TEXT NOT NULL,
  r2_key TEXT NOT NULL, -- R2 path only, signed URLs generated on-demand
  quantity INTEGER DEFAULT 1,
  FOREIGN KEY (order_id) REFERENCES orders(order_id)
);

-- Webhook idempotency (event_id PRIMARY KEY = auto-dedup)
CREATE TABLE webhook_events (
  event_id TEXT PRIMARY KEY, -- Stripe Event ID
  event_type TEXT NOT NULL,
  processed_at INTEGER NOT NULL
);

-- Performance indexes
CREATE INDEX idx_orders_user ON orders(firebase_uid, created_at DESC);
CREATE INDEX idx_webhook_events_processed ON webhook_events(processed_at);

Key Design Decisions:

  • webhook_events.event_id PRIMARY KEYINSERT INTO ... VALUES (event.id, ...) auto-fails on duplicate = idempotency
  • r2_key stored (not full URL) → signed URLs generated on-demand with expiry
  • stripe_payment_intent_id UNIQUE → prevents duplicate orders
  • No PII columns → all customer data fetched from Stripe API on-demand

Version History

  • v2.1.0 (2025-10-02): Design improvements (9 endpoints, /api/photos/verify added, Idempotency-Key removed, Rate Limiting clarified, D1 schema optimized)
  • v2.0.0 (2025-10-01): Minimal API design (8 endpoints, Cart removed, DO/Queues removed, R2 direct upload)
  • v1.0.0 (2025-09-17): Initial release with Shopify integration (deprecated)

Authentication

Firebase authentication and session management

Firebase JWT verification and BFF session management

Verify Firebase ID token and create BFF session.

Security:

  • Rate limiting: 10 requests/min per IP
  • Token validation: Firebase Admin SDK
  • Session TTL: 24 hours
Authorizations:
FirebaseAuth
Request Body schema: application/json
required
idToken
required
string

Firebase ID token

brand
required
string (Brand)
Enum: "neko" "tokinoe" "dog"

Supported brand names

Responses

Request samples

Content type
application/json
{
  • "idToken": "string",
  • "brand": "neko"
}

Response samples

Content type
application/json
{
  • "user": {
    },
  • "sessionToken": "string",
  • "expiresAt": "2019-08-24T14:15:22Z"
}

Products

Multi-brand product catalog

Get brand-specific products with intelligent caching

Retrieve products for a specific brand.

Cache Strategy:

  • Cloudflare Cache: 1 hour TTL
  • Cache Key: brand + limit + cursor
  • Revalidate on product updates
Authorizations:
FirebaseAuth
query Parameters
brand
required
string (Brand)
Enum: "neko" "tokinoe" "dog"

Supported brand names

limit
integer [ 1 .. 100 ]
Default: 20
cursor
string

Responses

Response samples

Content type
application/json
{
  • "products": [
    ],
  • "hasNext": true,
  • "nextCursor": "string"
}

Get single product details

Authorizations:
FirebaseAuth
path Parameters
id
required
string
query Parameters
brand
required
string (Brand)
Enum: "neko" "tokinoe" "dog"

Supported brand names

Responses

Response samples

Content type
application/json
{
  • "product": {
    }
}

Photos

R2 direct upload with presigned URLs (no Workers streaming)

Initialize R2 presigned URL for direct client upload

Returns R2 presigned PUT URL for client-side direct upload (no Workers streaming).

Flow:

  1. Client calls this endpoint
  2. BFF generates presigned URL (expires in 15 min)
  3. Client uploads directly to R2 using PUT request
  4. No Workers memory/CPU consumption

Why Direct Upload:

  • Workers streaming = memory/CPU exhaustion risk
  • R2 presigned URL = scalable, cost-effective
  • Client handles file upload (multipart for large files)

Photo Requirements:

  • Format: JPEG only
  • Dimensions: 1051x1051px (89mm square at 300dpi)
  • Max size: 10MB
  • Color space: sRGB
Authorizations:
FirebaseAuth
Request Body schema: application/json
required
brand
required
string (Brand)
Enum: "neko" "tokinoe" "dog"

Supported brand names

filename
required
string
content_type
required
string
Value: "image/jpeg"

Responses

Request samples

Content type
application/json
{
  • "brand": "neko",
  • "filename": "cat_photo.jpg",
  • "content_type": "image/jpeg"
}

Response samples

Content type
application/json
{}

Verify R2 photo upload completion

Verifies that a photo was successfully uploaded to R2 before allowing checkout.

Purpose:

  • Prevent checkout with non-existent photo URLs
  • Confirm R2 object exists via HEAD request
  • Store verified photo metadata in D1 for order processing

Flow:

  1. Client uploads to R2 using presigned URL from /api/photos/init
  2. Client calls this endpoint with photo_id
  3. BFF performs R2 HEAD request to verify object existence
  4. If exists, BFF stores photo metadata in D1
  5. Returns verification status

Security:

  • Firebase Auth required (must own the photo_id)
  • Rate limited by Cloudflare WAF
Authorizations:
FirebaseAuth
Request Body schema: application/json
required
photo_id
required
string

Photo ID returned from /api/photos/init

brand
required
string (Brand)
Enum: "neko" "tokinoe" "dog"

Supported brand names

Responses

Request samples

Content type
application/json
{
  • "photo_id": "photo_abc123xyz789",
  • "brand": "neko"
}

Response samples

Content type
application/json
{
  • "photo_id": "photo_abc123xyz789",
  • "r2_key": "photos/user123/abc123.jpg",
  • "verified_at": 1696345200,
  • "file_size": 2048576
}

Stripe Checkout

Stripe Checkout Session creation and payment processing

Create Stripe Checkout Session for photo print orders

Creates a Stripe Checkout Session for photo printing orders with built-in security features.

Security Features:

  • Firebase Auth required (emailVerified must be true)
  • Stripe auto-deduplication (same session ID = same checkout)
  • 30-minute session expiration

Metadata Stored (minimal, PII-free):

  • user_uid: Firebase UID for order tracking
  • brand: neko|tokinoe|dog
  • photo_ids: Array of verified photo IDs

Payment Flow:

  1. Client calls this endpoint with verified photo_ids and shipping details
  2. BFF validates Firebase token and user email verification
  3. BFF verifies all photo_ids exist in D1 (from /api/photos/verify)
  4. Stripe Checkout Session created with metadata
  5. Client redirects to Stripe-hosted checkout page
  6. After payment, webhook processes order asynchronously

Idempotency:

  • No client-side Idempotency-Key required
  • Stripe automatically handles duplicate requests with same session ID
  • BFF generates internal request hash for logging only
Authorizations:
FirebaseAuth
Request Body schema: application/json
required
brand
required
string (Brand)
Enum: "neko" "tokinoe" "dog"

Supported brand names

required
Array of objects (StripeCartItem) [ 1 .. 100 ] items

Cart items with photo references

required
object (ShippingAddress)

Japanese shipping address (PII - NEVER stored in D1).

Storage Policy:

  • ✅ Stripe: Full address stored
  • ❌ D1: NEVER store (PII violation)
  • ✅ Logs: Masked (postal_code: 150-, phone: 03--****)

Retrieval:

  • Fetch from Stripe API using customer_id or payment_intent_id
  • Cache in-memory during request (max 5 seconds)
  • Never persist in D1 database

Responses

Request samples

Content type
application/json
{
  • "brand": "neko",
  • "items": [
    ],
  • "shipping_address": {
    }
}

Response samples

Content type
application/json
{}

Stripe Webhook

Stripe webhook event handling with D1 idempotency (no Queues/DOs)

Stripe webhook event receiver with signature verification

Receives and processes Stripe webhook events with cryptographic signature verification.

Accepted Events:

  • checkout.session.completed: Payment successful, create order
  • checkout.session.expired: Session timeout, cleanup resources
  • payment_intent.succeeded: Payment confirmed
  • payment_intent.payment_failed: Payment failed, notify user
  • charge.refunded: Refund processed

Idempotency Guarantee via D1 UNIQUE constraint (no Queues/Durable Objects needed):

Flow:

  1. Verify Stripe signature header
  2. INSERT OR IGNORE INTO webhook_events(id, ...) VALUES (event.id, ...)
  3. If duplicate (UNIQUE constraint violation) → return 200 immediately
  4. If new → update orders/stock directly in D1 → return 200

Why This Works:

  • D1 event_id UNIQUE prevents duplicate processing
  • Stripe retries failed webhooks automatically
  • No need for Queues/Durable Objects at ~1000 orders/month scale

Note: If traffic exceeds 10k orders/month, consider adding Queues for SKU-level serialization.

header Parameters
stripe-signature
required
string
Example: t=1696345200,v1=abc123def456,v0=xyz789

Stripe webhook signature for verification

Request Body schema: application/json
required
id
string

Unique event identifier

type
string

Event type

data
object

Event payload data

Responses

Request samples

Content type
application/json
{
  • "id": "evt_1234567890abcdef",
  • "type": "checkout.session.completed",
  • "data": { }
}

Response samples

Content type
application/json
{
  • "received": true
}

Stripe Customer Portal

Self-service customer portal for payment and address management

Create Stripe Customer Portal session for self-service management

Creates a Stripe Customer Portal session URL for authenticated users to manage:

  • Shipping addresses
  • Payment methods (credit cards)
  • Order history and receipts

Prerequisites:

  • User must be authenticated with Firebase
  • User must have a Stripe Customer ID (created on first order)

Session Flow:

  1. User authenticated via Firebase token
  2. BFF retrieves Stripe Customer ID from D1
  3. Create Customer Portal session with return URL
  4. Client redirects to Stripe-hosted portal
  5. User returns to specified return_url after completion
Authorizations:
FirebaseAuth
query Parameters
return_url
string <uri>
Default: "https://neko.contents-print.jp/account"
Example: return_url=https://neko.contents-print.jp/account/settings

URL to redirect user after portal session ends

brand
required
string (Brand)
Enum: "neko" "tokinoe" "dog"

Brand context for portal customization

Responses

Response samples

Content type
application/json
{}

Orders

Order history retrieval (PII from Stripe API on-demand)

Get user's order history

Retrieve authenticated user's order history.

Data Source:

  • D1: Order metadata (Firebase UID, Stripe IDs, timestamps)
  • Stripe API: PII data (shipping address, customer details)

PII Policy:

  • NEVER stored in D1
  • Fetched from Stripe API on-demand
  • Masked in logs (postal_code: 150-, phone: 03--****)
Authorizations:
FirebaseAuth
query Parameters
brand
required
string (Brand)
Enum: "neko" "tokinoe" "dog"

Supported brand names

limit
integer [ 1 .. 100 ]
Default: 20
cursor
string

Responses

Response samples

Content type
application/json
{
  • "orders": [
    ],
  • "hasNext": true,
  • "nextCursor": "string"
}