Introduction

All four platforms share the same API surface

Ring & Weave, Grain & Tenon, Hide & Stitch, and Iron & Forge are built on an identical Lambda API — Cognito for auth, DynamoDB for storage. The only differences are the base URL and a handful of platform-exclusive endpoints (noted inline below: Ring & Weave has the Admin Portal and affiliate system; Grain & Tenon has the Community pattern-sharing endpoint). Use the platform selector in the sidebar to switch between base URLs in the examples below.

Request format

  • All POST request bodies are JSON. Include Content-Type: application/json.
  • GET requests pass parameters as query string values.
  • All responses are JSON with appropriate HTTP status codes, except /api/pattern-og which returns HTML.
  • Successful responses always include at minimum one of: ok, success, url, status, or the relevant data field.
  • Error responses always have the shape { "error": "human-readable message" }.
Base URLs

All endpoints are served from AWS API Gateway (HTTP API) backed by a single Lambda function per platform.

Platform Base URL
Ring & Weave https://hvkrj9y6i5.execute-api.us-east-2.amazonaws.com
Grain & Tenon https://fcvizsy6qc.execute-api.us-east-2.amazonaws.com
Hide & Stitch https://9khoalqxgi.execute-api.us-east-2.amazonaws.com
Iron & Forge https://sqzapgmlp5.execute-api.us-east-2.amazonaws.com
Authentication

All platforms — Cognito accessToken

Every platform (Ring & Weave, Grain & Tenon, Hide & Stitch, Iron & Forge) uses AWS Cognito for auth on its shared endpoints. Each platform has its own Cognito User Pool, but the request shape is identical everywhere. Protected endpoints accept the Cognito JWT in one of two ways:

  • POST requests: include accessToken as a field in the JSON request body (or as an Authorization: Bearer header — handlers check the header first, falling back to the body field).
  • GET requests: include Authorization: Bearer {accessToken} header.

Tokens are verified against each platform's own Cognito User Pool on every request. Tokens expire after 1 hour; refresh using the Cognito SDK currentSession().

Ring & Weave Admin Portal — PocketBase-backed, admin token

The Admin Portal endpoints (/api/admin-*) are exclusive to Ring & Weave and back the affiliate program. They're a separate auth path from the Cognito-protected endpoints above — see the Admin Portal section below for the login flow. Affiliate data itself lives in PocketBase (Ring & Weave is the only platform with a live PocketBase backend), accessed server-side only.

Public endpoints

These endpoints do not require authentication:

  • GET /api/health
  • GET /api/public-data
  • POST /api/analytics
  • POST /api/submit-feedback
  • POST /api/gift (purchase & verify actions only — redeem requires auth)
  • GET /api/pattern-og
  • GET /api/community (Grain & Tenon only — rating also public; share/unshare require auth)
  • POST /api/stripe-webhook (verified via HMAC-SHA256)
Errors & Rate Limits
HTTP status codes
Status Meaning
200 Success
202 Accepted — resource not ready yet (used by gift verify while pending)
400 Bad request — missing or invalid parameter
401 Unauthorized — invalid or expired Cognito access token
402 Payment required — subscription expired or purchase not active
403 Forbidden — not the owner of the resource (e.g. unsharing another user's Community pattern)
404 Not found — route or resource does not exist
409 Conflict — gift code already redeemed
429 Too many requests — rate limit exceeded
500 Internal server error
503 Service unavailable — dependency not configured (e.g., VAPID keys missing)
Rate limits (per IP, rolling 60-second window)
Endpoint Limit
/api/create-checkout-session5 req / min
/api/customer-portal5 req / min
/api/delete-account3 req / min
/api/submit-feedback5 req / min
/api/gift5 req / min
/api/verify-checkout10 req / min
/api/analytics60 req / min
/api/user-data30 req / min
/api/public-data60 req / min
/api/user-profile30 req / min
/api/push10 req / min
/api/pattern-og30 req / min
/api/export-data3 req / hr
/api/community (rate)10 req / min · Grain & Tenon only
/api/community (share/unshare)20 req / hr · Grain & Tenon only
/api/admin-login10 req / min · Ring & Weave only
/api/admin-affiliates10–30 req / min · Ring & Weave only
/api/admin-report10 req / min · Ring & Weave only
/api/admin-payout5 req / min · Ring & Weave only
/api/healthUnlimited (exempt)
/api/stripe-webhookUnlimited (exempt)
All other endpoints20 req / min (default)
Monitoring
GET

Health Check

GET /api/health
Returns the current health status of the API and a server timestamp. Use this endpoint for uptime monitoring and load-balancer health probes. Not rate-limited.
Response
FieldTypeDescription
status string Always "ok" when the function is running.
timestamp string (ISO 8601) Current server UTC time.
# Replace BASE_URL with the platform base URL above curl "/api/health"
const res = await fetch('/api/health') const data = await res.json() // { status: 'ok', timestamp: '2026-05-24T12:00:00.000Z' }
Example response
{ "status": "ok", "timestamp": "2026-05-24T12:00:00.000Z" }
Subscriptions
POST

Create Checkout Session

POST /api/create-checkout-session
Creates a Stripe Checkout session for a subscription or one-time lifetime purchase. Returns a redirect URL. Subscriptions include a 7-day free trial.
Request headers
HeaderDescription
Authorization Bearer {accessToken} — Cognito JWT. Required; the account to subscribe is derived from the token.
Request body
ParameterTypeDescription
plan string required Subscription tier. One of: monthly, yearly, threeyear, lifetime
userEmail string optional Pre-fills the email field in Stripe Checkout.
couponCode string optional Stripe coupon or promotion code to apply automatically.
Response
FieldTypeDescription
url string Stripe Checkout URL. Redirect the user to this URL immediately.
curl -X POST "/api/create-checkout-session" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer eyJ..." \ -d '{ "plan": "monthly", "userEmail": "user@example.com" }'
const { url } = await fetch('/api/create-checkout-session', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, }, body: JSON.stringify({ plan: 'monthly', userEmail: 'user@example.com', }), }).then(r => r.json()) window.location.assign(url) // redirect to Stripe
Example response
{ "url": "https://checkout.stripe.com/c/pay/cs_live_..." }
POST

Customer Portal

POST /api/customer-portal
Creates a Stripe Billing Portal session for the authenticated user. The portal allows cancelling subscriptions, updating payment methods, and viewing invoices.
Request body
ParameterTypeDescription
accessToken string required Cognito access token (or pass as an Authorization: Bearer header instead).
Response
FieldTypeDescription
url string Stripe Billing Portal URL. Redirect the user to this URL.
curl -X POST "/api/customer-portal" \ -H "Content-Type: application/json" \ -d '{"accessToken":"eyJ..."}'
const { url } = await fetch('/api/customer-portal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accessToken }), }).then(r => r.json()) window.location.assign(url)
POST

Verify Checkout

POST /api/verify-checkout
Checks the current subscription status for an authenticated user. Optionally pass a Stripe session ID to trigger a sync from Stripe if the webhook hasn't fired yet.
Request body
ParameterTypeDescription
accessToken string required Cognito access token (or pass as an Authorization: Bearer header instead).
sessionId string optional Stripe Checkout session ID (cs_live_...). When provided and the user is not yet subscribed in DynamoDB, the API queries Stripe directly and syncs the status.
Response
FieldTypeDescription
subscribed boolean true if the user has an active subscription.
trialing boolean true if the subscription is in a free trial.
trial_ends_at string | null ISO 8601 timestamp of when the trial ends, or null.
curl -X POST "/api/verify-checkout" \ -H "Content-Type: application/json" \ -d '{ "accessToken": "eyJ...", "sessionId": "cs_live_..." }'
const { subscribed, trialing, trial_ends_at } = await fetch('/api/verify-checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accessToken, sessionId }), }).then(r => r.json()) if (subscribed) console.log('Access granted')
Example response
{ "subscribed": true, "trialing": true, "trial_ends_at": "2026-05-31T12:00:00.000Z" }
Gift Codes
Note: All gift code actions use the same endpoint POST /api/gift with different action values.
POST

Purchase Gift Code

POST /api/gift action: "purchase"
Creates a Stripe Checkout session for a gift purchase. On payment completion, the webhook generates a unique gift code. The buyer polls /api/gift with action: "verify" using the returned nonce to retrieve the code once it's ready.
Request body
ParameterTypeDescription
action string required Must be "purchase".
plan string required Gift duration: monthly, yearly, threeyear, or lifetime.
buyerEmail string optional Pre-fills the buyer's email in Stripe Checkout.
Response
FieldTypeDescription
url string Stripe Checkout URL for the gift purchase.
nonce string 32-char hex identifier used to retrieve the generated code after payment completes.
POST

Verify Gift Code (poll after purchase)

POST /api/gift action: "verify"
Polls for the generated gift code after a successful purchase. Returns 202 { pending: true } while the Stripe webhook is still processing. Returns 200 with the code when it's ready.
Request body
ParameterTypeDescription
action string required Must be "verify".
nonce string required The nonce returned from the purchase step.
// Poll until the code is ready (Stripe webhook usually fires within 5s) async function pollGiftCode(nonce) { for (let i = 0; i < 12; i++) { await new Promise(r => setTimeout(r, 2500)) const res = await fetch('/api/gift', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'verify', nonce }), }) if (res.status === 200) { const { code, plan } = await res.json() return { code, plan } // e.g. { code: 'GIFT-AB12-CD34-EF56', plan: 'yearly' } } } throw new Error('Timed out waiting for gift code') }
POST

Redeem Gift Code

POST /api/gift action: "redeem"
Redeems a gift code for the authenticated user. Activates the subscription in DynamoDB and marks the code as used. Gift codes for lifetime plans grant permanent access; all other plans grant access for their duration (30, 365, or 1095 days).
Request body
ParameterTypeDescription
action string required Must be "redeem".
code string required Gift code in the format GIFT-XXXX-XXXX-XXXX. Case-insensitive.
accessToken string required Cognito access token of the account to apply the gift to (or pass as an Authorization: Bearer header instead).
Response
FieldTypeDescription
success boolean Always true on 200.
plan string The plan that was activated.
giftExpiresAt string | null ISO 8601 expiry timestamp, or null for lifetime codes.
Account
GET

User Profile

GET /api/user-profile
Returns the authenticated user's subscription status, plan, trial state, and billing portal availability. Shared across all four platforms.
Request headers
HeaderDescription
Authorization Bearer {accessToken} — Cognito JWT.
Response
FieldTypeDescription
emailstringUser's email address.
subscribedbooleantrue if the user has an active subscription or active gift access.
subscription_planstring | nullPlan name: monthly, yearly, threeyear, or lifetime.
trialingbooleantrue if the subscription is currently in a free trial.
trial_ends_atstring | nullISO 8601 trial end timestamp, or null.
cancel_at_period_endbooleantrue if the subscription is set to cancel at the end of the current period.
current_period_endstringISO 8601 timestamp of when the current billing period ends.
gift_expires_atstring | nullISO 8601 expiry of gift access, or null.
has_billing_portalbooleantrue if the user has a Stripe customer and can access the billing portal (not lifetime plans).
Example response
{ "email": "user@example.com", "subscribed": true, "subscription_plan": "yearly", "trialing": false, "trial_ends_at": null, "cancel_at_period_end": false, "current_period_end": "2027-06-10T00:00:00.000Z", "gift_expires_at": null, "has_billing_portal": true }
GET

Export Data

GET /api/export-data
Returns a full GDPR data export for the authenticated user as a downloadable JSON file. Shared across all four platforms (the specific data types included vary slightly — see each platform's Lambda for its exact list). Rate limited to 3 requests per hour.
Request headers
HeaderDescription
Authorization Bearer {accessToken} — Cognito JWT.
Response

Returns application/json with a Content-Disposition: attachment header (filename pattern varies per platform, e.g. grainandtenon-data-export-{id}.json). The body is a JSON object keyed by data type, each containing an array of records.

const res = await fetch('/api/export-data', { headers: { Authorization: `Bearer ${accessToken}` }, }) const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = 'chainmaille-data-export.json' a.click() URL.revokeObjectURL(url)
POST

Delete Account

POST /api/delete-account
Permanently deletes the authenticated user's account from Cognito and DynamoDB. Clears redeemed_by references on any gift codes the user claimed, then deletes the user record. This operation is irreversible.
Request body
ParameterTypeDescription
accessToken string required Cognito access token (or pass as an Authorization: Bearer header instead).
Response
{ "success": true }
User Data
Shared across all four platforms. All user data (custom weaves, inventory, projects, preferences, saved items, etc.) is stored in each platform's own DynamoDB table, keyed by Cognito sub.
POST

User Data CRUD

POST /api/user-data
Create, read, update, and delete user data records. All actions are scoped to the authenticated user. Supports 6 data types: custom_weave, inventory, inlay_project, preferences, project, saved_item.
Request body
ParameterTypeDescription
action string required One of: get, list, upsert, delete.
type string required Data type: custom_weave, inventory, inlay_project, preferences, project, or saved_item.
accessToken string required Cognito JWT.
id string optional Record ID. Required for get, upsert, and delete.
data object optional Record payload. Required for upsert.
GET

Public Data

GET /api/public-data?type={type}
Returns public read-only reference data: weaves, tutorials, suppliers, or patterns. No authentication required. Ring & Weave only.
Query parameters
ParameterTypeDescription
type string required One of: weaves, tutorials, suppliers, patterns.
GET

Community Patterns — List

GET /api/community
Returns up to 100 shared wood patterns with their average rating. No authentication required. Grain & Tenon only.
Response
FieldTypeDescription
patterns array Array of shared pattern objects, each including avgRating (rounded to 1 decimal) and ratingCount.
curl "/api/community"
POST

Community Patterns — Rate / Share / Unshare

POST /api/community
Rates, shares, or unshares a wood pattern, depending on action. Grain & Tenon only. rate is public; share and unshare require auth (and an active subscription or gift access for share).
Request body — action: "rate" (public)
ParameterTypeDescription
action string required Must be "rate".
patternId string required Alphanumeric (plus -/_), max 128 chars.
stars number required Integer 1–5.
Request body — action: "share" / "unshare" (auth required)
ParameterTypeDescription
action string required "share" or "unshare".
patternId string required Alphanumeric (plus -/_), max 128 chars.
data object required for share Pattern fields (name, type, difficulty, materials, steps, etc.). Serialized size capped at 50,000 chars; array fields are truncated (materials/steps to 50 items, tags to 20, tools to 30, woodTypes to 20).

Auth via Authorization: Bearer {accessToken} header. unshare requires the requester to be the pattern's original owner (403 otherwise).

Response
{ "ok": true }
curl -X POST "/api/community" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer eyJ..." \ -d '{ "action": "share", "patternId": "my-pattern-1", "data": { "name": "Cedar Bench", "type": "furniture" } }'
Push Notifications
Web Push (VAPID): The push system uses the browser-native Web Push API, not Firebase. The server stores PushSubscription objects and sends notifications via the VAPID protocol.
POST

Subscribe to Push Notifications

POST /api/push action: "subscribe"
Registers a browser PushSubscription for the authenticated user. If the same endpoint is already stored, it's replaced (deduplication). Requires notification permission to have been granted in the browser.
Request body
ParameterTypeDescription
action string required Must be "subscribe".
accessToken string required Cognito access token (or pass as an Authorization: Bearer header instead).
subscription object required Browser PushSubscription serialized to JSON, containing endpoint, keys.p256dh, and keys.auth.
const sw = await navigator.serviceWorker.ready const sub = await sw.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY, }) await fetch('/api/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'subscribe', accessToken, subscription: sub.toJSON(), }), })
POST

Send Push Notification

POST /api/push action: "send"
Sends a push notification to all registered devices for the authenticated user. Expired or revoked subscriptions (HTTP 410 from the push service) are automatically removed.
Request body
ParameterTypeDescription
action string required Must be "send".
accessToken string required Cognito access token (or pass as an Authorization: Bearer header instead).
title string required Notification title. Truncated to 100 chars.
body string required Notification body text. Truncated to 200 chars.
Response
FieldTypeDescription
sent number Number of notifications successfully delivered.
Feedback & Analytics
POST

Submit Feedback

POST /api/submit-feedback
Submits user feedback. Sends an email notification to the team via AWS SES — not persisted to a database. If an email address is provided, the user receives an acknowledgement email.
Request body
ParameterTypeDescription
rating number required Integer from 1 to 5.
likes string optional What the user liked about the app.
improvements string optional What could be improved.
suggestions string optional Feature suggestions.
email string optional User's reply-to email. If valid, a confirmation is sent.
curl -X POST "/api/submit-feedback" \ -H "Content-Type: application/json" \ -d '{ "rating": 5, "likes": "The AR calculator is incredible", "suggestions": "Dark mode option", "email": "maker@example.com" }'
await fetch('/api/submit-feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rating: 5, likes: 'The AR calculator is incredible', suggestions: 'Dark mode option', email: 'maker@example.com', }), })
POST

Track Event

POST /api/analytics
Records an analytics event in DynamoDB. Event names are sanitized to alphanumeric + underscore characters and truncated at 64 chars. Properties are stored as a JSON string (max 512 bytes). No PII should be passed as event properties.
Request body
ParameterTypeDescription
event string required Event name. Use snake_case. Non-alphanumeric characters are stripped.
properties object optional Arbitrary key/value metadata. Serialized and truncated to 512 bytes.
fetch('/api/analytics', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'weave_viewed', properties: { weave_id: 'box-chain', source: 'search' }, }), }) // fire-and-forget — don't await
Content
GET

Pattern OG Preview

GET /api/pattern-og?id={id}
Returns a static HTML page with generic Open Graph meta tags, then redirects to the in-app pattern viewer via <script>window.location.replace(...)</script>. Used as the social share URL for patterns. Does not currently look up the pattern by id — title/description are fixed generic strings; the id is only used to build the redirect URL. Responses are cached at the edge for 5 minutes (s-maxage=300).
Query parameters
ParameterTypeDescription
id string required Pattern ID used to build the redirect URL. Alphanumeric only, max 20 chars.
Response

Returns text/html with generic OG tags (app name, a fixed description, and a static cover image) and a client-side redirect.

Webhooks
POST

Stripe Webhook

POST /api/stripe-webhook
Receives Stripe webhook events and syncs subscription state to DynamoDB. Register this endpoint in the Stripe Dashboard under Webhooks. Not rate-limited.
HMAC-SHA256 verification required. Every request is verified using the stripe-signature header. Requests with invalid or missing signatures are rejected with 400.
Required headers
HeaderDescription
stripe-signature Stripe's HMAC signature header. Generated automatically by Stripe; do not forge.
Content-Type Must be application/json — sent by Stripe automatically.
Events handled
Event typeAction
customer.subscription.created Activates subscription, stores plan, trial end, cancellation state, and Stripe IDs. Sends welcome email to subscriber.
customer.subscription.updated Syncs subscription status, cancel_at_period_end, current_period_end, and trial state changes.
customer.subscription.deleted Sets subscribed: false and clears plan.
checkout.session.completed For payment-mode sessions: activates lifetime plans or generates gift codes. Idempotent — duplicate Stripe sessions are ignored.
invoice.payment_failed Sends payment failure email to subscriber. Fires on attempt 1 and on final failure (when next_payment_attempt is null).
Gift code generation

When a gift purchase checkout session completes, the webhook generates a code with the format GIFT-XXXX-XXXX-XXXX using 48 bits of cryptographic entropy (crypto.randomBytes(6)). Gift codes are stored in each platform's own gift_codes DynamoDB table and linked to the Stripe session ID for idempotency.

Stripe Dashboard configuration
SettingValue
Webhook URL/api/stripe-webhook
Events to sendcustomer.subscription.*, checkout.session.completed, invoice.payment_failed
API versionLatest (set by Stripe SDK version)
Admin Portal
Internal use only. These endpoints power the admin.fignolemetalworks.com portal. All admin endpoints require a session token obtained from POST /api/admin-login passed as Authorization: Bearer {token}.
POST

Admin Login

POST /api/admin-login
Authenticates with the admin secret and returns a session token. The token is an HMAC-SHA256 digest of the secret — stateless, no expiry.
Request body
ParameterTypeDescription
password string required The ADMIN_SECRET environment variable value.
Response
{ "token": "a3f8b2..." }
GET

List Affiliates

GET /api/admin-affiliates?status={status}
Returns all affiliate records from PocketBase, optionally filtered by status.
Query parameters
ParameterTypeDescription
status string optional Filter by status: pending, active, or rejected.
POST

Manage Affiliates

POST /api/admin-affiliates
Create, approve, or update affiliate records. When approving, automatically creates a Stripe coupon and sends an approval email to the affiliate.
Request body
ParameterTypeDescription
action string optional "approve" to approve an application and create a Stripe coupon. Omit to create or update a record.
id string optional PocketBase record ID. Required for approve and update actions.
couponCode string optional Desired Stripe coupon code. Required when action: "approve". Sanitized to uppercase alphanumeric, max 20 chars.
discountPercent number optional Discount percentage for the Stripe coupon. Defaults to 10.
GET

Commission Report

GET /api/admin-report?month={YYYY-MM}
Returns a monthly commission report for all active affiliates. Calculates earnings by coupon usage, applies the 30-day payout hold, checks the $25 minimum, and includes any rolled-over balances from prior periods.
Query parameters
ParameterTypeDescription
month string required Report month in YYYY-MM format (e.g. 2026-06).
Response (array of rows)
FieldTypeDescription
affiliateobjectFull affiliate record from PocketBase.
monthlyCountnumberMonthly plan conversions via this affiliate's coupon.
yearlyCountnumberYearly plan conversions.
threeyearCountnumber3-year plan conversions.
lifetimeCountnumberLifetime plan conversions.
earnednumberEarnings for this period (USD).
rolledBalancenumberRolled-over balance from prior periods below the $25 minimum.
totalnumberearned + rolledBalance.
holdClearedbooleantrue when the 30-day hold has passed for this period.
payablenumberAmount ready to pay out. 0 if hold not cleared or below $25 minimum.
alreadyPaidbooleantrue if a payout record already exists for this period.
POST

Run Payout

POST /api/admin-payout
Executes affiliate payouts for a given period. Supports single-affiliate or bulk payAll mode. PayPal payouts are sent automatically via the Payouts API; Stripe payouts are recorded and require a manual balance transfer in the Stripe dashboard. Sends a payout confirmation email to each affiliate paid. Records rolled balances for affiliates under the $25 minimum.
Request body
ParameterTypeDescription
period string required Payout period in YYYY-MM format.
method string required "paypal" or "stripe".
affiliateId string optional Pay a single affiliate. Required unless payAll: true.
payAll boolean optional When true, pays all eligible affiliates for the period in a single batch.
Payout rates
PlanCommission
Monthly$2.00
Yearly$10.00
3-Year$20.00
Lifetime$30.00
Example response
{ "success": true, "count": 3, "message": "Paid 3 affiliates (PayPal batch: 5C2K4FXNPQR8A)" }
Budget Tracker (separate product)

Not part of the four-platform craft-app API

Budget Tracker is a standalone Fignole Metalworks product (a private family budget app) with its own Lambda, its own DynamoDB tables, and its own auth model — a custom HMAC-SHA256 JWT, not Cognito or PocketBase. It does not use the platform selector above; its base URL is fixed.

Platform Base URL
Budget Tracker https://budget.fignolemetalworks.com

Auth — custom JWT

Sign in via POST /api/login to get a token (30-day expiry, HMAC-SHA256, password hashed with PBKDF2/SHA-512). Pass it on protected routes as Authorization: Bearer {token}.

POST

Login

POST https://budget.fignolemetalworks.com/api/login
Public. Verifies credentials against the Users DynamoDB table and issues a JWT.
Request body
ParameterTypeDescription
username string required Case-insensitive; stored lowercase.
password string required Checked against a PBKDF2 hash.
Response
FieldTypeDescription
tokenstringJWT, 30-day expiry. Store and send as a Bearer token.
usernamestringNormalized (lowercase) username.
displayNamestringDisplay name shown in the app header.
curl -X POST "https://budget.fignolemetalworks.com/api/login" \ -H "Content-Type: application/json" \ -d '{"username":"jb","password":"yourpassword"}'
Example response
{ "token": "eyJ...", "username": "jb", "displayName": "JB" }
POST

Add User (admin)

POST https://budget.fignolemetalworks.com/api/admin/add-user
Creates or updates a user account (e.g. a family member). Protected by a separate admin secret (stored in SSM), not a user JWT.
Request body
ParameterTypeDescription
adminSecret string required Must match the deployment's admin secret. 403 if wrong.
username string required Normalized to lowercase on save.
password string required Stored as a PBKDF2/SHA-512 hash, never in plaintext.
displayName string optional Defaults to username if omitted.
Response
{ "ok": true, "username": "mom" }
GET

Get Data

GET https://budget.fignolemetalworks.com/api/data
Returns the authenticated user's saved budget data, or null if nothing has been saved yet.
Request headers
HeaderDescription
AuthorizationBearer {token} from /api/login.
Response

Returns the raw stored payload (shape is whatever the client last saved via PUT /api/data — currently { data, ids }), or null.

PUT

Save Data

PUT https://budget.fignolemetalworks.com/api/data
Overwrites the authenticated user's saved budget data with the request body.
Request headers
HeaderDescription
AuthorizationBearer {token} from /api/login.
Request body

Any JSON-serializable payload — stored as-is and returned unchanged by GET /api/data.

Response
{ "saved": true }