Skip to content
Shiny.NET
Commercial SupportGuaranteed response times on Shiny bug fixes, straight from the maintainers. Plans from US$1,500 a year.See the plans

NFC

NuGet package Shiny.AppDeviceBridge.Nfc

The NFC bridge reads and writes NDEF tags (URLs, text, MIME payloads, smart posters and Android app records) for the page. It’s built on Shiny.Nfc. A WebView has no Web NFC (neither Android’s WebView nor WKWebView implements it), so this is the page’s only way to reach the NFC radio.

Platform Backed by
Android Reader mode on the current activity
iOS CoreNFC, with the system scan sheet. No tag UID
Windows, macOS, Linux A PC/SC contactless reader such as an ACR122U (Linux: pcscd)
Mac Catalyst, a device with no NFC nothing: every route answers 501
Terminal window
dotnet add package Shiny.AppDeviceBridge.Nfc
builder.UseAppDeviceBridge(
bridge => bridge.AddNfcBridge(),
webApp => { /* … */ }
);

Android needs <uses-permission android:name="android.permission.NFC" />. iOS needs NFCReaderUsageDescription and the com.apple.developer.nfc.readersession.formats entitlement with TAG. See Shiny.Nfc.

For a single read or write, the bridge starts a scan, handles the first tag, and ends the scan:

import { NfcBridge } from "@shinyorg/appdevicebridge";
const nfc = new NfcBridge();
const read = await nfc.read({ alertMessage: "Hold your phone near the tag" });
const url = read?.records?.find(r => r.uri)?.uri;
const { written } = await nfc.write({
records: [{ uri: "https://shinylib.net" }, { text: "Shiny" }]
});

read answers 204 (null) when the scan ended before a tag was tapped, for example when the user dismissed the iOS sheet. write then answers { written: false }. Aborting the request ends the scan.

To keep scanning, listen for nfc.tag first (the scan answers 409 not_listening otherwise), then start it. Each tag arrives with its message already read:

// subscribe before starting - the scan needs a listener
const stopTags = await nfc.onTag(tag => {
console.log(tag.identifier, tag.records); // identifier is null on iOS
});
await nfc.onScanEnded(e => console.log("scan ended:", e.reason)); // Stopped, Dismissed or Failed
await nfc.startScan({ alertMessage: "Tap your badge" });
// …
await nfc.stopScan();

The scan also stops when the page stops listening for nfc.tag, so a page that navigates away doesn’t leave the iOS sheet up. Only one NFC session runs at a time: a one-tap call during a scan answers 409 nfc_busy. Before starting, the scan answers 409 nfc_unavailable when NFC is switched off on Android.

A native tag only exists while the scan is handling it, so to write to or lock a tag from nfc.tag, scan with holdSeconds (up to 60). The scan then holds each tag until the page releases it or the hold runs out. A tag tapped meanwhile waits its turn.

await nfc.onTag(async tag => {
if (!tag.records) {
await nfc.writeTag(tag.tagId, { records: [{ uri: `https://example.com/asset/${crypto.randomUUID()}` }] });
}
await nfc.releaseTag(tag.tagId); // move on to the next tag now
});
await nfc.startScan({ holdSeconds: 10 });

tagId is the bridge’s handle for the held tag, not its UID. Once the tag is released, its routes answer 404 tag_not_held. With holdSeconds left at 0, the default, every tag is released straight away. That’s the setting for read-only scanning, such as a check-in kiosk.

lockTag(tagId) makes the tag read-only. This is permanent: no app can ever write to it again.

A record from a tag carries its raw parts (format, type, base64 payload, id) plus the decoded value for the common types: uri (URI records and smart posters), text and language, and mimeType.

To write, set exactly one kind per record:

Record Fields
URI { uri }
Text { text, language? } (en by default)
MIME { mimeType, data } (base64)
Android Application Record { androidPackage }: opens, or offers to install, the app
Raw { format, type, data }, for example { format: "External", type: "example.com:thing", data }

Any record can also set id. A message has 1 to 32 records and at most 32 KB.

Status Code When
400 bad_request A record isn’t exactly one kind, the message is empty or too large, or holdSeconds is over 60
404 tag_not_held The tag behind tagId was released, its hold ran out, or the scan stopped
409 nfc_busy, not_listening, nfc_unavailable Another NFC session is running, nobody listens for nfc.tag, or NFC is off
409 tag_read_only, tag_not_supported The tag is locked, or can’t hold NDEF or be locked
410 tag_lost The tag left the field. A write cut short may have destroyed the previous message
413 message_too_large The message doesn’t fit on the tag. Nothing was written
422 invalid_ndef The tag holds bytes that aren’t NDEF
502 nfc_failed Anything else the radio reported
GET/_bridge/nfc#

What this platform can do, and whether a scan is running.

C#INfcBridge.GetStatusAsyncTSNfcBridge.getStatus
Response
200 NfcStatus
NfcStatus · 2 fields
capabilitiesNfcCapability[]What this platform can do; empty when there is no NFC hardware.
scanningbooleanWhether a scan session is running.
POST/_bridge/nfc/access#

Whether NFC is present and switched on. Nothing prompts: NFC needs no runtime permission.

C#INfcBridge.RequestAccessAsyncTSNfcBridge.requestAccess
Response
200 NfcAccessResult
NfcAccessResult · 1 field
accessAccessStateAvailable, Disabled (Android: NFC switched off in Settings) or NotSetup (a desktop with no PC/SC reader plugged in yet).
POST/_bridge/nfc/read#

Waits for one tag and reads it. Null when the scan ended first (iOS: the user dismissed the sheet) or the request was aborted. Fails with 422 invalid_ndef when the tag holds bytes that are not NDEF.

C#INfcBridge.ReadAsyncTSNfcBridge.read
Body
application/json · NfcReadRequest
NfcReadRequest · 1 field
alertMessagestring | nulliOS: the prompt on the scan sheet, such as "Hold your iPhone near the tag".
Response
200 NfcReadResult · or 204 when there is none
NfcReadResult · 2 fields
identifierstring | nullThe tag's UID as hex, or null where the platform does not expose it (iOS).
recordsNfcRecord[] | nullThe tag's NDEF records, or null when it holds no message.
Errors
422Invalid state
POST/_bridge/nfc/write#

Waits for one tag and writes a message to it. Fails with 400 for a malformed record, 409 tag_read_only, 413 message_too_large (nothing written) and 410 tag_lost (the tag left mid-write; its previous message may be gone).

C#INfcBridge.WriteAsyncTSNfcBridge.write
Body
application/json · NfcWriteRequest
NfcWriteRequest · 2 fields
recordsNfcWriteRecord[]The message to write, one or more records.
alertMessagestring | nulliOS: the prompt on the scan sheet.
Response
200 NfcWriteResult
NfcWriteResult · 1 field
writtenbooleanTrue once written; false when the scan ended before a tag was tapped (iOS: the user dismissed the sheet).
Errors
400Bad request409Conflict410Gone413Too large
POST/_bridge/nfc/scan#

Starts a scan session. Each tag fires nfc.tag with its message already read; the session runs until stopScan, the page stops listening, or the platform ends it (nfc.scan). Listen for nfc.tag first, or it answers 409 not_listening.

C#INfcBridge.StartScanAsyncTSNfcBridge.startScan
Body
application/json · NfcScanRequest
NfcScanRequest · 2 fields
alertMessagestring | nulliOS: the prompt on the scan sheet.
holdSecondsnumberHow long, up to 60, each tag stays available to writeTag and lockTag after nfc.tag fires, unless released sooner. The next tag waits until then. 0 (the default) releases every tag straight away, for read-only scanning.
Response
204 No content
Errors
409Conflict
DELETE/_bridge/nfc/scan#

Ends the scan session. Does nothing when none is running.

C#INfcBridge.StopScanAsyncTSNfcBridge.stopScan
Response
204 No content
POST/_bridge/nfc/tags/{tagId}/write#

Writes to the tag a scan is holding (see holdSeconds). 404 tag_not_held once it has been released; otherwise the same errors as write.

C#INfcBridge.WriteTagAsyncTSNfcBridge.writeTag
ParameterInType
tagIdpathstringrequired
Body
application/json · NfcTagWriteRequest
NfcTagWriteRequest · 1 field
recordsNfcWriteRecord[]The message to write to the held tag.
Response
204 No content
Errors
404Not found
POST/_bridge/nfc/tags/{tagId}/lock#

Locks the held tag so it can never be written again. This is permanent.409 tag_not_supported when the tag cannot be locked.

C#INfcBridge.LockTagAsyncTSNfcBridge.lockTag
ParameterInType
tagIdpathstringrequired
Response
204 No content
Errors
409Conflict
POST/_bridge/nfc/tags/{tagId}/release#

Lets the scan move on to the next tag before the hold runs out. Does nothing for a tag already released.

C#INfcBridge.ReleaseTagAsyncTSNfcBridge.releaseTag
ParameterInType
tagIdpathstringrequired
Response
204 No content
EVENTnfc.tag#

A tag came into range during a scan.

C#INfcBridge.OnTagAsyncTSNfcBridge.onTag
Payload
NfcTag
tagIdstringThe handle for the tag routes while it is held. Not the UID.
identifierstring | nullThe tag's UID as hex, or null where the platform does not expose it (iOS).
isWritablebooleanThe tag is NDEF formatted (or formattable) and not locked.
maxSizenumber | nullThe largest message, in encoded bytes, the tag holds; null when unknown.
recordsNfcRecord[] | nullThe tag's NDEF records, already read; null when it holds no message.
readErrorstring | nullWhy the message could not be read, when it could not.
EVENTnfc.scan#

The scan session ended, and why.

C#INfcBridge.OnScanEndedAsyncTSNfcBridge.onScanEnded
Payload
NfcScanEnded
reasonNfcScanEndReasonWhy the scan ended.
messagestring | nullThe platform's reason, for Failed.

JSON property names are camelCase and enums travel as their names. A ? marks a property the payload may omit;| null one that may be null.

NfcStatusobject#
capabilitiesNfcCapability[]What this platform can do; empty when there is no NFC hardware.
scanningbooleanWhether a scan session is running.
NfcCapabilityenum#

Something this platform's NFC can do.

"ReadNdef""WriteNdef""MakeReadOnly""TagIdentifier"
NfcAccessResultobject#
accessAccessStateAvailable, Disabled (Android: NFC switched off in Settings) or NotSetup (a desktop with no PC/SC reader plugged in yet).
AccessStateenum#

Where a permission stands. Reported by every bridge that needs one; the names match Shiny's own.

"Unknown""NotSupported""NotSetup""Disabled""Restricted""Denied""Available"
NfcReadRequestobject#
alertMessage?string | nulliOS: the prompt on the scan sheet, such as "Hold your iPhone near the tag".
NfcReadResultobject#
identifierstring | nullThe tag's UID as hex, or null where the platform does not expose it (iOS).
recordsNfcRecord[] | nullThe tag's NDEF records, or null when it holds no message.
NfcRecordobject#

An NDEF record read from a tag: its raw parts, plus the decoded value for the common types.

formatNfcTypeNameFormatThe record's type name format.
typestringThe record type: U, T, Sp, a MIME type or an external type.
payloadstringThe raw payload, base64 in JSON.
idstring | nullThe record ID, when it has one.
uristring | nullA URI record's URI, or a smart poster's.
textstring | nullA text record's text, or a smart poster's first title.
languagestring | nullA text record's language code.
mimeTypestring | nullA MIME record's type.
NfcTypeNameFormatenum#

How an NDEF record's type is read (its TNF).

"Empty""WellKnown""Media""AbsoluteUri""External""Unknown"
NfcWriteRequestobject#
recordsNfcWriteRecord[]The message to write, one or more records.
alertMessage?string | nulliOS: the prompt on the scan sheet.
NfcWriteRecordobject#

One record to write. Set exactly one of: uri; text (with language); mimeType with data; androidPackage; or the raw format, type and data.

uri?string | nullA URI record, with the standard prefix compression.
text?string | nullA UTF-8 text record.
language?string | nullThe text record's language code; en when omitted.
mimeType?string | nullA MIME record of this type, carrying data.
data?string | nullThe payload for a MIME or raw record, base64 in JSON.
androidPackage?string | nullAn Android Application Record: opens, or offers to install, this app.
format?NfcTypeNameFormat | nullA raw record's type name format.
type?string | nullA raw record's type.
id?string | nullThe record ID, for any kind.
NfcWriteResultobject#
writtenbooleanTrue once written; false when the scan ended before a tag was tapped (iOS: the user dismissed the sheet).
NfcScanRequestobject#
alertMessage?string | nulliOS: the prompt on the scan sheet.
holdSeconds?numberHow long, up to 60, each tag stays available to writeTag and lockTag after nfc.tag fires, unless released sooner. The next tag waits until then. 0 (the default) releases every tag straight away, for read-only scanning.
NfcTagWriteRequestobject#
recordsNfcWriteRecord[]The message to write to the held tag.
NfcTagobject#

A tag came into range during a scan.

tagIdstringThe handle for the tag routes while it is held. Not the UID.
identifierstring | nullThe tag's UID as hex, or null where the platform does not expose it (iOS).
isWritablebooleanThe tag is NDEF formatted (or formattable) and not locked.
maxSizenumber | nullThe largest message, in encoded bytes, the tag holds; null when unknown.
recordsNfcRecord[] | nullThe tag's NDEF records, already read; null when it holds no message.
readErrorstring | nullWhy the message could not be read, when it could not.
NfcScanEndedobject#
reasonNfcScanEndReasonWhy the scan ended.
messagestring | nullThe platform's reason, for Failed.
NfcScanEndReasonenum#

Why a scan session ended.

"Stopped""Dismissed""Failed"