EdgeNFC
Sign in Start free

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 a 4xx. Branch on the boolean, not the HTTP status. Use read_ctr (monotonic) and replay to build one-time claims: a replayed URL comes back non_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:

ModeQuery parametersUID on the wire
Mirror-plainsys, uid, ctr, macmirrored in plaintext
Encrypted-PICCsys, picc_data, cmacencrypted 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:false with reason: "rate_limited" — but the two routes report it with different statuses, on purpose. /verify answers 429 with a retry-after header, because a machine can act on that. /t answers 200 and renders the ordinary tap page with plain copy, because a person just tapped a tag and a 4xx invites 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 /verify in 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, not 403 — the resource's existence is hidden. Do not treat 404 as "deleted".
  • Feature gates: entitlement is checked at config time, never on the hot tap path. Below the required plan you get 402 feature_required (or 402 license_required for the perpetual Edge license). Handle these as "upgrade needed", not as a bug.
  • Roles: inside an org (Enterprise), a member without the owner/admin role on a management action gets 403 forbidden.
  • Provisioning sync: the provisioning app authenticates to POST /api/tags/import with 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 by uid).
  • 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 (Enterprise mgmt_api; 402 feature_required otherwise).

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.

  • 2xx marks the delivery done. Anything else is retried on later sweeps, up to 5 attempts total, after which it is marked failed and 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 uid and counter, so the same tap always produces the same evt_… 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 /verify with a genuine tap → authentic:true and an advanced read_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 v1 digest — then tap a genuine tag and watch a scan.verified land with the same read_ctr the /verify call reported.

Next steps#