| ← Back to s.plings.io | Core Systems |
URL Structure & Parameters
Created: Tue 29 Jul 2025 06:49:39 CEST
Updated: Wed 06 May 2026 17:14:18 CEST - Phase 1 naming migration: clarified transport-agnostic URL format (QR / NFC / RFID); resolved native-app integration architecture (apps call GraphQL API directly; Gateway handles browser path)
Updated: Sun 14 Jun 2026 10:17:12 CEST - Scan landing Phase 1: routing convergence (all known objects → plings.io/o/{oid}); new sid redirect parameter; new unverified error code; UNKNOWN now routes to /welcome
Updated: Sun 14 Jun 2026 11:24:14 CEST - Path format corrected to base58-encoded segments (was wrongly documented as decimal integers 1–999999); matches the Gateway validator and the minting tool.
Updated: Mon 15 Jun 2026 - Error handling aligned to implemented firewall behavior: rate-limited requests return HTTP 429 (not a redirect) and internal errors return HTTP 500; added the fixed details token table (incl. bad_transport for t not in q/n/r/b).
Updated: Sun 21 Jun 2026 - added 1D Code128 identifier: payload format, transport t=c, resolution model, and Gateway bypass note.
Updated: Fri 26 Jun 2026 11:12:05 CEST - Documented variable path depth (5–10 levels): new “Path Depth & Sub-Batches” section explaining positional role assignment (last two segments are always batch.instance, middle is a 0…N category list), per-level value ranges, the role-shift caveat when adding depth, and flat-numbering vs. nested-sub-batch issuance. Clarified the Path Structure Breakdown as the 5-level minimum, not a fixed structure.
Updated: Fri 26 Jun 2026 14:40:00 CEST - Per-level value cap reconciled to the BIP32 ceiling 2³¹−1 (uniform across levels); removed the stale small decimal-cap table (legacy spärr with no rationale). Gateway now rejects segments above the ceiling. ADR-0051.
Document Version: 1.8 - per-level value cap = BIP32 ceiling (2³¹−1); legacy decimal caps removed
Security Classification: Public Technical Documentation
Target Audience: Frontend Developers, Backend Developers
Author: Paul Wisén
Overview
This document details the URL structure and parameter specifications for the s.plings.io Gateway service. The same URL format is used for every scan transport — QR codes, NFC tags, RFID tags, and (future) Bluetooth beacons all encode this identical URL.
Incoming Scan URL Format
Base Structure
https://s.plings.io?t=<transport>&i=<instance>&p=<path>&cp=<class_pointer>
Core Parameters
| Parameter | Name | Required | Description | Valid Values | Example |
|---|---|---|---|---|---|
t |
Transport | Yes | Scan transport | q, n, r, b |
q (QR code) |
i |
Instance Key | Yes | Unique instance identifier | Base58 string | 4kyQCd5tMDjJVWJH5h95gUcjq3qTX2cj5nwjVyqBRwLo |
p |
Path | Yes | HD wallet derivation path | Dot-separated base58 segments | 4.2.3.3.6 |
cp |
Class Pointer | No | Cryptographic class identifier | Base58 string | 4K7mX9abDcE |
Extended Parameters (Future)
| Parameter | Name | Description | Example |
|---|---|---|---|
tx |
Transaction | Transaction type | buy, rent, service |
svc |
Service | Service identifier | repair, insurance |
price |
Price | Amount in cents | 2500 |
dur |
Duration | Time period | 1d, 1w, 1m |
Path Structure Breakdown
The path parameter (p) follows the HD wallet hierarchy. The minimum, canonical form is 5 levels:
p=4.2.3.3.6
│ │ │ │ └── Instance ID
│ │ │ └──── Batch/Series
│ │ └────── Category
│ └──────── Plant/Location
└────────── Manufacturer ID
The structure is variable-depth, not fixed at 5. See Path Depth & Sub-Batches below for how levels are interpreted when a path is deeper, and how to organise issuance into nested sub-batches.
Path Depth & Sub-Batches
A Plings path is variable-depth: it may have between 5 and 10 levels (the 10-level ceiling is enforced by the Gateway validator — see Path validation). The depth is not fixed because the middle of the path is an arbitrary-length list of category levels.
How depth is interpreted
The parser (Plings-API/scripts/utils/path_utils.py) assigns roles positionally, anchored from both ends:
4 . 1 . 2 . 7 . 3 . 1
│ │ └───┬───┘ │ └── instance (always the LAST segment)
│ │ │ └──── batch (always the SECOND-TO-LAST segment)
│ │ └──────────── categories (ZERO OR MORE middle segments)
│ └──────────────────── manufacturer
└──────────────────────── wallet version
- First segment = wallet version.
- Second segment = manufacturer.
- Last segment = instance.
- Second-to-last segment = batch.
- Everything in between = an ordered list of category levels (0…N, bounded only by the 10-level total).
⚠️ Roles shift when you add depth. Because
batchandinstanceare always the last two segments, adding a level re-labels the segment that used to be the batch into a category. Going from3.1.2.1.5(instance 5 of batch 1) to3.1.2.1.5.5does not add a sub-level under instance 5 — it makes[2, 1]the categories,5the batch, and the new trailing5the instance. Plan the full depth up front for a given issuance line rather than appending levels later.
Per-level value range
Every level shares the same bounds: 1 … 2³¹−1 (2 147 483 647). The position determines a
level’s semantic role (wallet / manufacturer / category / batch / instance), not its allowed
magnitude. The ceiling is the BIP32 hardened-index limit — each level becomes a hardened index
value + 0x80000000 that must fit in 32 bits.
Earlier revisions of this doc listed small per-level decimal caps (wallet ≤ 99, manufacturer ≤ 999, category ≤ 9999, batch/instance ≤ 99999). Those were an undesigned legacy spärr and have been removed — they had no rationale and contradicted the Gateway. See [ADR-0051]. The only structural limit besides this value ceiling is the 10-level depth cap.
Flat numbering vs. nested sub-batches
Within a path you “own” (e.g. 3.1.2.1 = wallet 3 · manufacturer 1 · category 2 · batch 1), there are two valid ways to issue identifiers:
- Flat: keep the batch fixed and number instances directly —
3.1.2.1.1,3.1.2.1.2, … up to3.1.2.1.99999. - Nested sub-batches: add depth so each sub-batch gets its own batch level, each with its own instance range —
3.1.2.1.1.1…100,3.1.2.1.2.1…100, … . Here the original1becomes a category, the new fifth segment is the batch, and the sixth is the instance.
Both are fully supported. Nested sub-batches are useful for separating issuance lines (per product type, per print run, per delegated range) while keeping a shared prefix. The only hard constraint is the 10-level total — deep nesting plus the wallet/manufacturer/batch/instance anchors still has to fit inside 10 segments — and the per-level ranges above.
1D Code128 Payload (<prefix>-<path>)
Code128 is a linear (1D) barcode used for long round objects — fishing rods, cable sections — where a 2D QR code does not fit due to the object’s curvature. Only a narrow flat strip along the long axis faces the camera; a 1D barcode oriented along that axis fits this geometry perfectly.
Payload format
<prefix>-<path>
prefix— the first N base58 characters of the instance key (a MAC over the path). Minimum N = 8; recommended default N = 12. The hyphen-is the delimiter: it is not in the base58 alphabet, so splitting on the first-unambiguously yieldsprefix(left) andpath(right).path— the HD-wallet derivation path in base58 wire form (dot-separated segments), identical to the value the QR URL carries inp=.
Example payload at prefix length 12:
CCuejfEAmFGK-4.2.3.2.2
Not a URL
The 1D payload is deliberately not a URL. The stock iOS Camera does not reliably follow a link from a 1D scan (that behaviour is effectively a QR feature), so Code128 scanning is app-scan territory regardless. Native apps decode the barcode, parse the <prefix>-<path> token, and call the GraphQL API directly — 1D scans bypass the Gateway entirely.
Transport tag t=c
The transport value for Code128 is t=c. As with all transports, it is analytics only — routing never branches on transport. Because the 1D payload is not a URL, the barcode itself carries no t field; the scanner sets t=c itself when it decodes a Code128 symbol and passes it to the GraphQL API.
Normalization to the unified resolution model
After parsing, both a QR URL and a 1D Code128 payload normalize to the same canonical tuple:
URL "…?t=q&i=<full>&p=<path>" ─┐
├─► { path, keyMaterial } ──► one resolver + verifier
1D "<prefix>-<path>" ─┘
keyMaterial is the full instance key (from QR) or the scanned prefix (from 1D) — the same axis at different lengths. Resolution and verification proceed identically:
- Resolve the
ObjectInstancebypath(the value common to every identifier form). - Verify by comparing the scanned
keyMaterial(length L) against the stored instance key truncated to L. A match confirms the identifier to strength L.
The backend enforces a minimum prefix length (≥ 8 characters) and rejects shorter prefixes.
Outgoing Redirect Parameters
After processing, s.plings.io enriches the URL with additional parameters:
Standard Redirect Parameters
| Parameter | Name | When Included | Description | Example |
|---|---|---|---|---|
oid |
Object ID | When object exists | Database UUID for object | obj-550e8400-e29b-41d4 |
ikey |
Instance Key | Always | Original scanned identifier | 4kyQCd5tMDjJVWJH5h95gUcjq3qTX2cj5nwjVyqBRwLo |
path |
HD Path | Always | Base58-encoded wallet path | 4.2.3.3.6 |
class |
Class ID | When resolved | Class UUID | 550e8400-e29b-41d4-a716-446655440000 |
cptr |
Class Pointer | When available | Cryptographic class pointer forwarded from incoming cp |
4K7mX9abDcE |
sid |
Scan Event ID | When scan event is logged | Neo4j scan event node ID; used by the browser landing page to enrich the event with coarse geolocation in Phase 2 | scan_789xyz |
src |
Source | Always | Tracking source | scan |
loc |
Location | With consent | GPS coordinates | 59.3293,18.0686 |
ts |
Timestamp | Optional | Scan timestamp | 1699123456789 |
URL Transformation Examples
Example 1: Known Object for Sale
Input:
https://s.plings.io?t=q&i=4kyQCd5tMDjJVWJH5h95gUcjq3qTX2cj5nwjVyqBRwLo&p=4.2.3.3.6
Processing:
- Identifier verified ✓
- Object found in database
- Status: FOR_SALE
- Scan event logged (sid=scan_789xyz)
- Route to /o/:oid (status-aware; page renders "For Sale" notice)
Output:
https://plings.io/o/obj-123?oid=obj-123&ikey=4kyQCd5tMDjJVWJH5h95gUcjq3qTX2cj5nwjVyqBRwLo&path=4.2.3.3.6&sid=scan_789xyz&src=scan
Example 2: Unknown Generic Tag
Input:
https://s.plings.io?t=q&i=7mnPQd8uNEkKWXKI6i06hVdjr4rUY3dk6oxkWyrCSwMp&p=1.1.1.1.1
Processing:
- Identifier format valid ✓
- No object found in database
- Generic Plings tag (no class pointer)
- Scan event logged (sid=scan_456def)
- Route to unknown-tag onboarding
Output:
https://plings.io/welcome?ikey=7mnPQd8uNEkKWXKI6i06hVdjr4rUY3dk6oxkWyrCSwMp&path=1.1.1.1.1&sid=scan_456def&src=scan
Example 3: Manufacturer Pre-printed Tag
Input:
https://s.plings.io?t=q&i=9abCDe3vOPqRStUvWxYz&p=17.3.42.1.1001&cp=3K7mX9
Processing:
- No object instance found
- Class pointer present (forwarded as cptr=3K7mX9)
- Scan event logged (sid=scan_abc123)
- Route to unknown-tag onboarding (class-pointer-enriched; /welcome shows class info if class system resolves cptr — stubbed for now)
Output:
https://plings.io/welcome?ikey=9abCDe3vOPqRStUvWxYz&path=17.3.42.1.1001&cptr=3K7mX9&sid=scan_abc123&src=scan
Service-Specific Routing
v1 routing (current): all known objects (any status with a resolved object_id) route to the
single status-aware landing page at plings.io/o/{oid}. The page branches on the object’s status
itself. Unknown/unregistered tags route to the onboarding page.
| Object Status | Destination (v1) | Notes |
|---|---|---|
| NORMAL | plings.io/o/{oid} |
Standard object page |
| FOR_SALE | plings.io/o/{oid} |
Page shows “for sale” notice; transaction handled in app |
| FOR_RENT | plings.io/o/{oid} |
Page shows “for rent” notice; transaction handled in app |
| LENDABLE | plings.io/o/{oid} |
Page shows “lendable” notice; transaction handled in app |
| LOST | plings.io/o/{oid} |
Page shows “lost” alert with prominent contact-owner CTA |
| UNKNOWN (no object_id) | plings.io/welcome |
Unknown/unregistered tag onboarding page |
Future work: Dedicated subdomains (
market.plings.io,rent.plings.io,lend.plings.io) are planned for when commerce features are built. They will receive the sameoidand scan parameters. Switching to them does not require reprinting physical identifiers — only a Gateway routing rule change.
Parameter Validation Rules
Instance Key (i)
- Must be valid Base58 string
- Length: 32-48 characters
- No special characters except Base58 alphabet
Path (p)
- Dot-separated base58-encoded segments (each segment is a base58 number, Bitcoin alphabet)
- Maximum 10 levels deep
- Each segment: 1–11 base58 characters (base58 alphabet
[1-9A-HJ-NP-Za-km-z]; excludes 0, O, I, l) - Each segment’s decoded value is 1 … 2³¹−1 (the BIP32 hardened-index ceiling — every level becomes a hardened index
value + 0x80000000). A larger value can never be a valid Plings path; the Gateway rejects it. There is no per-level decimal cap below this (the old wallet ≤99 / batch ≤99999 caps were removed). See [ADR-0051]. - Small values look identical in decimal and base58 (e.g.
4.2.3.3.6); larger instance indices include letters (e.g.4d= 210) - Example:
4.2.3.2.4d
Transport (t)
- Enum:
q(QR),n(NFC),r(RFID),b(Bluetooth — future),c(Code128 1D barcode) - Case sensitive
- Default:
qif missing - The Gateway’s routing logic does not branch on transport — this parameter exists for analytics and rare transport-specific edge cases only
cis set by the native scanner itself; Code128 scans bypass the Gateway and reach the API directly (see the 1D Code128 Payload section)
Class Pointer (cp)
- Optional but recommended for manufacturer tags
- Base58 encoded
- Used for offline verification
Error Handling
Invalid or unverifiable parameters result in a 302 redirect to the single /scan-error page.
Plings-Web exposes one /scan-error React route, so the destination is always a
query-param route — a sub-path like /scan-error/invalid would render NotFound. The
details value is always one of a fixed set of tokens — raw user input is never echoed:
https://plings.io/scan-error?reason=invalid&details=<token>
https://plings.io/scan-error?reason=unverified
details token |
Meaning |
|---|---|
missing_params |
Required parameter i or p absent |
bad_transport |
t is not one of q, n, r, b, c |
bad_instance_key |
i is not Base58 or not 32–48 characters |
bad_path |
p violates the path rules above |
bad_class_pointer |
cp is not Base58 |
Rate-limited requests do not redirect (forwarding a flood to plings.io would defeat the
firewall). They receive an HTTP 429 JSON response with Retry-After and X-RateLimit-*
headers. Internal errors return an HTTP 500 JSON response with a sanitized message —
internal error details never leave the Gateway.
Error reason codes
reason |
Meaning | User-facing message intent |
|---|---|---|
invalid |
The code is structurally malformed (bad Base58, missing required parameters) | “This code is not a valid Plings identifier” |
unverified |
The code is well-formed but could not be verified as genuine (cryptographic verification failed or is pending) | “We could not verify this tag is genuine” |
system |
Internal server error (also returned as an HTTP 500 JSON response rather than a redirect) |
“Something went wrong on our end” |
Note on
unverified: Cryptographic genuineness verification (HD wallet signature check) is stubbed as of Phase 1. Theunverifiederror path is wired and the/scan-errorpage renders appropriate genuineness messaging, but the actual verification logic is not yet implemented. This will be completed in a future phase.
Security Considerations
- All parameters are validated and sanitized
- SQL injection prevention on all inputs
- XSS protection through proper encoding
- Rate limiting applied per parameter combination
- Invalid UTF-8 sequences rejected
Native App Integration
Native apps (Plings-iOS, Plings-Android — planned) bypass the Gateway and call the GraphQL API directly. This is intentional: native NFC interactions need to be instant, and a network roundtrip to s.plings.io purely for redirection would defeat the point.
The unified contract is the GraphQL API at api.plings.io, not the Gateway’s HTTP front door. The Gateway exists for entry points that don’t have a native app to do their own resolution (browser, e-mail link, OS camera).
Native scan flow
- User taps an NFC tag (or scans a QR) inside the native app
- The app parses the URL parameters from the scan payload — same parameters documented above
- The app calls a GraphQL query (e.g.,
resolveIdentifier) with the parsedi/p/cp/tvalues - The app routes the user locally based on the response (object data, status, action hints)
- The app reports a scan event back to the API for centralized analytics
// Native scan path — call GraphQL directly
const { data } = await api.resolveIdentifier({
instanceKey: params.i,
path: params.p,
classPointer: params.cp,
transport: params.t,
});
// Then act on data.object.status / data.routing locally
When the Gateway still handles a scan from a phone with the app installed
When a s.plings.io URL is opened from outside the app (a friend shares the link, the user scans with the OS camera and chooses the browser, etc.), the OS hands the URL to whichever app is registered. If the Plings app is registered as a universal/app link handler, it can intercept and run the native flow. Otherwise the browser hits s.plings.io and the Gateway routes normally.
Where centralized concerns live
Logging, rate limiting, validation, and authorization are not the Gateway’s exclusive responsibility — they live behind the GraphQL API and apply to every caller, native or web. The Gateway adds an extra firewall layer specifically for browser-side abuse (DDoS, automated scrapers); native apps can’t reach the API without their own credentials and rate limits.