🥤BevFacts

Technical / Part 5

Part 5 — APIs

Three surfaces: URL deep links (live), the in-page JavaScript API (live), and the hosted REST API (draft, stable enough to build against).

DBNF-1 · Part 5 §5.1–5.2 Live §5.3–5.7 Draft

5.1URL deep-link API

Live Implemented in the reference app. Every label is addressable; a link is a label.

The generator persists its full state in the URL fragment. Encoding algorithm, precisely:

fragment = base64( utf8( percentEncode( JSON.stringify(state) ) ) )
url      = origin + path + "#" + fragment

percentEncode is ECMAScript encodeURIComponent. The double encoding is deliberate: it keeps the base64 alphabet ASCII-safe for any drink name in any script. Decoders run the inverse and MUST treat an unparseable fragment as "no state" (never an error page — a smudged QR still opens the app).

5.1.1 State object

Flat key/value, using the reference app's field names. Unknown fields are ignored; missing fields keep defaults. This is the compatibility contract: a QR printed today MUST resolve under every future 1.x release.

FieldTypeDefaultMeaning
drinkNamestringDisplay name
servingSizestringHuman form, e.g. "16 fl oz (473 mL)"
servingsint ≥ 11Servings per container
sweetness0–150100Percent
pumps0–120Extra syrup pumps
shots0–80Extra espresso shots
calories … caffeinenumber0The 12 base nutrient fields (app names: calories, totalFat, satFat, transFat, cholesterol, sodium, totalCarb, fiber, sugars, addedSugars, protein, caffeine)
depositnumber1.00Cup deposit, display currency
cupIdhex stringautoPart 6 format
showDepositboolfalseRender the deposit strip

5.1.2 Building links from any language

# shell + python
STATE='{"drinkName":"Taro Milk Tea","sweetness":50,"sugars":48,"addedSugars":46,"calories":420}'
FRAG=$(python3 -c "import base64,urllib.parse,sys;print(base64.b64encode(urllib.parse.quote(sys.argv[1]).encode()).decode())" "$STATE")
echo "https://bevfacts.com/index.html#$FRAG"

Fragments SHOULD stay under 2,048 bytes (QR capacity at EC level M; see Part 6). The app also applies fragments live via hashchange — navigating an open generator to a new deep link re-renders without a reload.

5.2JavaScript API

Live Exposed as window.BevFacts on the generator page — usable from the console, a kiosk shell, or an embedding iframe.
MemberSignatureReturns
BevFacts.versionstring"1.0-draft"
BevFacts.DVobjectPart 3 §3.3 daily-value table
BevFacts.compute(state)(Partial<State>) → NutrientsUnrounded as-ordered vector; sweetness/pumps/shots applied per Part 2
BevFacts.round{calories, fat, cholesterol, sodium, gram}Part 3 §3.2 declaration-rounding functions
BevFacts.link(state)(Partial<State>) → string§5.1 deep-link URL
const facts = BevFacts.compute({ sugars: 33, addedSugars: 28, calories: 250,
                                 caffeine: 150, sweetness: 100, pumps: 3, shots: 2 });
// → { calories: 320, sugars: 48, addedSugars: 43, caffeine: 300, ... }
BevFacts.round.calories(facts.calories)                       // → 320
Math.round(100 * facts.addedSugars / BevFacts.DV.addedSugars) // → 86  (%DV, unrounded input — Part 3 §3.1)
BevFacts.link({ drinkName: "Ube Latte", sugars: 40 })         // → shareable URL

compute is pure and synchronous — kiosks can call it on every input event. The Part 2 §2.10 and Part 3 §3.7 test vectors are all executable against this surface.

5.3REST API

Draft Specified ahead of hosting so POS and printer vendors can build against a stable wire format. The client-side surfaces above are the interim reference.
PropertyValue
Base URLhttps://api.bevfacts.example/v1
Media typeapplication/json; charset=utf-8 both directions
Spec pinningDBNF-Version: 1.0-draft request header; response echoes the version served
IdempotencyIdempotency-Key (UUID) honored on all POSTs for 24 h
PaginationCursor-based: ?limit=50&cursor=…; response carries next_cursor or null
ClockAll timestamps RFC 3339 UTC

5.4Auth & environments

Authorization: Bearer bf_live_9a8b7c…     # production
Authorization: Bearer bf_test_1f2e3d…     # sandbox — same API, synthetic fleet, no money

Keys are per-location. Sandbox and live objects never mix; a bf_test_ key reading a live cup returns 404, not 403 — existence itself is scoped. Keys MUST be server-side only; the browser surfaces (§5.1–5.2) exist so front-ends never need one.

5.5Endpoints

POST/v1/labels

Compute (and optionally render) a label. Body: { drink, base, modifiers, cup?, render? } per Part 4; render ⊆ ["png","svg","json","linear"] (linear = Part 1 §1.5 string).

HTTP/1.1 201 Created
Location: /v1/labels/lbl_9f2ke1
{
  "id": "lbl_9f2ke1",
  "document": { "…": "full Part-4 DBNF document, incl. computed/declared/warnings" },
  "hash": "e3b0c44298fc1c14…",
  "share_url": "https://…/index.html#eyJkcmlua05hbWUi…",
  "renders": { "png": "https://…/lbl_9f2ke1@3x.png", "svg": "https://…/lbl_9f2ke1.svg" }
}
GET/v1/labels/{label_id}

Retrieve a document by id or by canonical hash (/v1/labels/sha256:…). Immutable; served with Cache-Control: immutable.

GET/v1/presets

The vendor's published base recipes — the L3 open-data surface. Filters: ?q= (name), ?category=, ?updated_since=. Each item is a Part-4 drink + base pair with a vendorSku.

GET/v1/milks · GET/v1/toppings · GET/v1/syrups

The vendor's modifier tables (Part 2 §2.4.1, §2.6). Consumers MUST prefer these over the reference defaults when present.

GET/v1/cups/{cup_id}

{ id, state, deposit, issued_at, cycles, last_scan_at } — states per Part 6 §6.4.

POST/v1/cups/{cup_id}/return

Record a return scan. Body: { "location_id": "loc_042", "refund_via": "store_credit" | "card" | "points" }. Returns the refund instruction; emits cup.returned. Returning a cup already in returned/washing state is a 409 invalid_transition.

POST/v1/webhook_endpoints · DELETE/v1/webhook_endpoints/{id}

Manage webhook subscriptions (§5.6).

5.6Webhooks

Events: label.created, cup.returned, cup.lost (TTL expiry), recipe.updated. Delivery is at-least-once; consumers dedupe on event.id. Signature scheme (constant-time compare, 5-minute tolerance window against replay):

X-DBNF-Signature: t=1751565840, v1=hex( HMAC_SHA256( secret, "{t}.{raw_body}" ) )

# verify (pseudocode)
expected = hmac_sha256(endpoint_secret, header.t + "." + raw_body)
valid    = constant_time_eq(expected, header.v1) && |now − header.t| ≤ 300
{
  "id": "evt_7d1a2b",
  "type": "cup.returned",
  "created": "…",
  "data": { "cup_id": "A92F-3", "location_id": "loc_042",
            "cycles": 15, "refund": { "amount": 1.00, "currency": "USD", "via": "store_credit" } }
}

Failed deliveries retry with exponential backoff (1 m, 5 m, 30 m, 2 h, 12 h) then park; parked events are re-playable for 30 days via GET /v1/events?cursor=….

5.7Errors & rate limits

Error envelope, always:

HTTP/1.1 422 Unprocessable Entity
{
  "error": {
    "code": "nutrient_conflict",
    "message": "addedSugars_g (40) exceeds sugars_g (33) in base",
    "param": "base.addedSugars_g",
    "doc_url": "https://…/technical/calculation.html#invariants"
  }
}
StatusCodeMeaning
400invalid_modifierUnknown modifier type, or field outside its Part-4 domain
400malformed_documentFails the Part-4 JSON Schema
401unauthorizedMissing/invalid bearer key
404not_foundUnknown id (or wrong environment — see §5.4)
409invalid_transitionCup state machine violation (Part 6 §6.4)
409idempotency_conflictSame key, different body
422nutrient_conflictPart 2 §2.7 invariant violated
429rate_limitedHonor Retry-After

Rate limits (draft): 60 req/min per key sustained, burst 120; headers X-RateLimit-Limit / -Remaining / -Reset on every response. POST /v1/labels during store hours is the hot path — implementations SHOULD compute locally (§5.2 is enough) and use the API for persistence, renders, and the audit trail rather than per-order round-trips.