Custom web app#
For teams building their own registration flow or experience on top of EdgeNFC: verify taps server-side, get a signed callback the moment a tag is verified, read analytics, and manage the tag registry — all over plain HTTPS/JSON, callable from any language. There is no SDK to install; the whole surface is REST plus outbound webhooks, so a curl and an HTTP handler are the only prerequisites.
Verify a tap server-side (GET /verify)#
Point your tags' SUN URLs at your own handler, or read the tap params and call GET /verify yourself. It is public, unauthenticated, and rate-limited.
curl "https://edgenfc.com/verify?sys=sys_01H…&uid=04a1b2c3d4e580&ctr=000042&mac=…"A genuine tap returns 200:
{ "authentic": true, "uid": "04a1b2c3d4e580", "read_ctr": 42,
"replay": "ok", "first_seen": false, "verified_at": "2026-08-01T12:00:00Z" }A forged, malformed, or replayed tap also returns 200, with authentic:false and a reason:
{ "authentic": false, "reason": "mac_mismatch" }reason is one of mac_mismatch | malformed | non_monotonic | unknown_uid.
Important
A forgery is a valid request with a false result:
200+authentic:false, never a4xx. Branch on the boolean, not the HTTP status. Useread_ctr(monotonic) andreplayto build one-time claims: a replayed URL comes backnon_monotonic.
Encrypted-PICC mode (picc_data + cmac)#
GET /verify takes the tap parameters in either of the two URL modes the tag can be configured for — pass through whatever the tag emitted:
| Mode | Query parameters | UID on the wire |
|---|---|---|
| Mirror-plain | sys, uid, ctr, mac | mirrored in plaintext |
| Encrypted-PICC | sys, picc_data, cmac | encrypted inside picc_data |
GET /verify?sys=<system>&picc_data=<hex>&cmac=<hex>In encrypted mode the UID and counter travel encrypted; the verifier decrypts them with the system's meta-read key before checking the MAC and the counter. (That key is per system, not per tag — decryption is what reveals which tag sent the tap, so it cannot depend on knowing the UID first. See the key-management playbook for what that costs and what it does not.) The response shape is identical — you still get { authentic, uid, read_ctr, replay, reason }, with uid recovered from the decrypted payload. Your application code does not branch on the mode; only the tag's provisioning does.
Encrypted-PICC has been verified end to end on a physical NTAG 424 DNA against a live verifier, so you can write the encrypted branch against a mode that is real. What does not exist yet is a dashboard control for it: the mode is fixed when a tag is provisioned, so treat which mode your tags emit as a decision made upstream of your code rather than a per-request setting. Handling both shapes costs you nothing today and means a later switch does not need a deploy.
Do not try to parse picc_data yourself, and do not log it alongside anything that would let you correlate it back to a person — hiding the UID is the entire point of the mode.
Rate limits and errors#
/verify and the /t tap page are public and rate-limited per client and system with a fixed window. Two things to know:
- Being over budget carries the same body shape a forgery does —
authentic:falsewithreason: "rate_limited"— but the two routes report it with different statuses, on purpose./verifyanswers429with aretry-afterheader, because a machine can act on that./tanswers200and renders the ordinary tap page with plain copy, because a person just tapped a tag and a4xxinvites the browser to show its own error page instead. Either way: branch on the boolean in the body, not on the status. - Budgets are deployment configuration, not part of the contract. Do not build a client that hammers
/verifyin a loop; verify once per tap, cache your own result, and key it to(uid, read_ctr).
The verify path never returns secret material and compares MACs in constant time, whatever the verdict.
Read analytics (GET /api/analytics, Brand+)#
Aggregate scan data for one system — total scans, the authentic/failed split, a by-country breakdown, and a per-day series. Owner-scoped (404 on a system you don't own) and gated on the Brand plan or higher (402 feature_required).
curl "https://edgenfc.com/api/analytics?sys=sys_01H…" \
-H "authorization: Bearer $TOKEN"{ "system_id": "sys_01H…", "window_days": 30, "total": 128, "authentic": 120, "failed": 8,
"by_country": [ { "country": "US", "count": 90 }, { "country": "GB", "count": 30 } ],
"by_day": [ { "day": "2026-07-15", "count": 12 } ] }The response is privacy-preserving by construction: only counts, ISO-2 country codes, and dates — never a UID, IP, or personal field.
Management auth#
Everything under /api/… is authenticated and owner-scoped, in contrast to the public verify surface:
- Auth: a bearer session token —
authorization: Bearer $TOKEN. There is no separate API key today, and the System Master Key is never an API credential: it derives tag keys, it does not authenticate calls. Never put it in a header, a query string, or a client bundle. - Owner scoping: a system, tag or org belonging to another account returns
404, not403— the resource's existence is hidden. Do not treat404as "deleted". - Feature gates: entitlement is checked at config time, never on the hot tap path. Below the required plan you get
402 feature_required(or402 license_requiredfor the perpetual Edge license). Handle these as "upgrade needed", not as a bug. - Roles: inside an org (Enterprise), a member without the
owner/adminrole on a management action gets403 forbidden. - Provisioning sync: the provisioning app authenticates to
POST /api/tags/importwith a scoped provisioning token — again, not the master key.
Manage tags (management API)#
Drive your registry from your app with the authenticated management routes — all owner-scoped (a resource in another account returns 404):
GET /api/tags?sys=…— list/search the registry.POST /api/tags/import— bulk import a provisioning-app registry export (idempotent byuid).GET /api/tags/{uid}— tag detail (key version, last counter, last seen).PATCH /api/tags/{uid}— re-point (route_url),revoke, or re-activate a tag without re-writing hardware (Enterprisemgmt_api;402 feature_requiredotherwise).
Management errors use one envelope — { "error": { "code", "message" } } — with code in unauthorized | not_found | rate_limited | validation | feature_required | license_required | quota_exceeded. See the REST API reference for the full contract.
Outbound webhooks (Brand+)#
Instead of polling /verify, let us call you: subscribe an HTTPS endpoint and every authentic tap arrives as a signed POST within minutes. That is what turns a tap into a product registration, a loyalty credit, or a CRM record without you writing a poller.
Configure endpoints in the dashboard under Webhooks, or over the management API. Both paths are Brand plan or higher — below that you get 402 feature_required, checked at config time, never on the tap path.
curl -X POST https://edgenfc.com/api/webhooks \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{ "url": "https://hooks.example.com/edgenfc",
"events": ["scan.verified"], "system_id": "sys_01H…" }'The response is the only time the signing secret is ever shown:
{ "id": "whe_…", "url": "https://hooks.example.com/edgenfc", "events": ["scan.verified"],
"system_id": "sys_01H…", "status": "active", "secret": "whsec_…",
"warning": "shown once — store it now; it is never displayed again" }Store it in your secret manager on receipt. It is envelope-encrypted at rest and never returned by any list or read call, so a leaked session token cannot hand an attacker the ability to forge deliveries to you. Lost it? Delete the endpoint and add it again to mint a new one.
Omit system_id to receive taps from every campaign on the account. You may have up to 5 endpoints per account — a bounded fan-out keeps one account from turning a tap into an unbounded burst of outbound requests; a sixth returns 409 limit_reached.
The scan.verified event#
scan.verified is the one event that ships today. It fires on an authentic tap with an advanced counter — never on a forgery, a replay, or a malformed request, so anything that reaches your handler has already passed the MAC and monotonic-counter checks.
{ "id": "evt_…", "type": "scan.verified", "created": "2026-08-01T12:00:00Z",
"system_id": "sys_01H…",
"data": { "uid": "04a1b2c3d4e580", "read_ctr": 42, "replay": "ok",
"verified_at": "2026-08-01T12:00:00Z",
"route_url": "https://shop.example.com/registered" } }The body is assembled field by field from an explicit whitelist rather than serialized from a database row, which is why key material, billing state and other accounts' data have no path into your receiver even by accident. uid and route_url are present only when the tap carried them.
Verify the signature#
Every delivery carries EdgeNFC-Signature: t=<unix>,v1=<hex>, where v1 is HMAC_SHA256(secret, "{t}.{rawBody}") — the same construction Stripe uses, so if you already verify Stripe webhooks you can reuse that code path. Two rules make the signature worth having:
- Sign the raw body, before any JSON parsing or re-serialization. A re-encoded body will not match.
- Reject a timestamp older than 300 seconds and compare the digests in constant time. The freshness window is what stops someone replaying a delivery they captured, and constant-time comparison is what stops them learning the right digest a byte at a time.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const mine = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const theirs = Buffer.from(v1, "hex");
return mine.length === theirs.length && timingSafeEqual(mine, theirs);
}Two more headers ride along: EdgeNFC-Event (the event type) and EdgeNFC-Delivery (the delivery id, identical to the payload's id).
Where we will and will not deliver#
An endpoint URL must be HTTPS on port 443, with no credentials in the URL, and must not resolve to a loopback, private, link-local, carrier-grade-NAT or cloud-metadata address. Single-label hosts and .local/.internal/.home.arpa names are refused too. A rejected URL comes back as 400 invalid_url with the specific reason.
This rule is enforced twice — when you save the endpoint, and again immediately before every single delivery. The second check is the one that matters: a hostname that passed at save time can be re-pointed at 169.254.169.254 afterwards, and re-validating against the address we are about to contact is what keeps our egress from being used as a proxy into anyone's private network. For the same reason redirects are never followed; a 3xx is treated as a failed delivery, not as a new destination.
Retries and idempotency#
Delivery is at-least-once, off the hot path. A tap only enqueues a row — the outbound POST happens in a sweep that runs every five minutes — so a receiver that is down, slow or hostile can never slow down or fail a tap for the person holding the tag.
2xxmarks the delivery done. Anything else is retried on later sweeps, up to 5 attempts total, after which it is markedfailedand shown in the dashboard's delivery log. Each request is time-bounded, so one hung receiver cannot stall the queue behind it.- A delivery blocked by the pre-send address check fails immediately and is not retried — the URL is not deliverable, and re-checking it later would only burn attempts.
- Deduplicate on the delivery id. It is derived from the endpoint plus the tap's
uidand counter, so the same tap always produces the sameevt_…id no matter how many times it is enqueued or retried. Treat your handler as idempotent keyed on that id and at-least-once delivery costs you nothing.
Return 2xx as soon as you have durably accepted the event; do your slow work afterwards. A handler that does five seconds of CRM work before answering will look like a timeout and get retried.
Test before you ship tags#
Each endpoint has a Send test button in the dashboard (POST /api/webhooks/{id}/test). It delivers a synthetic ping event immediately, signed with your real secret, so you can prove your receiver and your signature check work before a single tag is in the field. The dashboard's Recent deliveries table (GET /api/webhooks/deliveries) then shows status, attempt count and the last HTTP code you returned — that is where to look when a delivery is not arriving.
SDK — planned (not yet available)#
There is no published SDK yet. For in-app or edge verification today, embed the Wasm core directly (the same Rust core the hosted gateway and the Android app run), as shown in the DIY Edge Core guide — TypeScript types ship with the WASM package. A packaged JS/Wasm SDK with a stable surface is on the roadmap; until then, integrate over REST or against the Wasm module directly.
Verify it worked#
- Call
GET /verifywith a genuine tap →authentic:trueand an advancedread_ctr. - Replay the same params →
authentic:false,reason: non_monotonic. - Call
GET /api/analytics?sys=…and confirm the authentic/failed counts move as you test. - Add a webhook endpoint, hit Send test, and confirm your handler recomputes the same
v1digest — then tap a genuine tag and watch ascan.verifiedland with the sameread_ctrthe/verifycall reported.
Next steps#
- REST API reference — every endpoint, shape, and error code.
- E-commerce / Shopify — route authentic taps to a destination.
- DIY Edge Core — embed the Wasm core and keep key custody.
EdgeNFC