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).
5.1URL deep-link API
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.
| Field | Type | Default | Meaning |
|---|---|---|---|
drinkName | string | — | Display name |
servingSize | string | — | Human form, e.g. "16 fl oz (473 mL)" |
servings | int ≥ 1 | 1 | Servings per container |
sweetness | 0–150 | 100 | Percent |
pumps | 0–12 | 0 | Extra syrup pumps |
shots | 0–8 | 0 | Extra espresso shots |
calories … caffeine | number | 0 | The 12 base nutrient fields (app names: calories, totalFat, satFat, transFat, cholesterol, sodium, totalCarb, fiber, sugars, addedSugars, protein, caffeine) |
deposit | number | 1.00 | Cup deposit, display currency |
cupId | hex string | auto | Part 6 format |
showDeposit | bool | false | Render 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
window.BevFacts on the generator page — usable from the console, a kiosk shell, or an embedding iframe.| Member | Signature | Returns |
|---|---|---|
BevFacts.version | string | "1.0-draft" |
BevFacts.DV | object | Part 3 §3.3 daily-value table |
BevFacts.compute(state) | (Partial<State>) → Nutrients | Unrounded 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
| Property | Value |
|---|---|
| Base URL | https://api.bevfacts.example/v1 |
| Media type | application/json; charset=utf-8 both directions |
| Spec pinning | DBNF-Version: 1.0-draft request header; response echoes the version served |
| Idempotency | Idempotency-Key (UUID) honored on all POSTs for 24 h |
| Pagination | Cursor-based: ?limit=50&cursor=…; response carries next_cursor or null |
| Clock | All 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
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" }
}
Retrieve a document by id or by canonical hash (/v1/labels/sha256:…). Immutable; served with Cache-Control: immutable.
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.
The vendor's modifier tables (Part 2 §2.4.1, §2.6). Consumers MUST prefer these over the reference defaults when present.
{ id, state, deposit, issued_at, cycles, last_scan_at } — states per Part 6 §6.4.
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.
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"
}
}
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_modifier | Unknown modifier type, or field outside its Part-4 domain |
| 400 | malformed_document | Fails the Part-4 JSON Schema |
| 401 | unauthorized | Missing/invalid bearer key |
| 404 | not_found | Unknown id (or wrong environment — see §5.4) |
| 409 | invalid_transition | Cup state machine violation (Part 6 §6.4) |
| 409 | idempotency_conflict | Same key, different body |
| 422 | nutrient_conflict | Part 2 §2.7 invariant violated |
| 429 | rate_limited | Honor 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.