# Web NFC API

> NDEFReader.scan() delivers NDEF messages from NFC tags as reading events and write() pushes records to a tag. Record members, every exception, examples.

`NDEFReader` exchanges NFC Data Exchange Format (NDEF) messages with tags held against the phone: `scan()` starts listening and fires a `reading` event with the tag's serial number and records each time one comes into range, `write()` pushes a message to the next tag, and `makeReadOnly()` locks a tag permanently. A PWA uses it for inventory labels, event check-in, or pairing with a device that carries an NFC sticker; low-level tag protocols outside NDEF are not exposed.

Chrome 89 for Android is the only implementation: it ships `NDEFReader`, `scan()`, `write()`, and the `reading` and `readingerror` events, and Chrome 100 for Android added `makeReadOnly()` (BCD `api.NDEFReader`). Chromium browsers on Android that mirror Chrome, such as Samsung Internet, inherit it; desktop Chrome, Firefox, and Safari report no support, and WebKit has filed an "oppose" position ([standards-positions #584](https://github.com/WebKit/standards-positions/issues/584)).

## Syntax

```js
const reader = new NDEFReader()

reader.scan()
reader.scan(options)
reader.write(message)
reader.write(message, options)
reader.makeReadOnly()
reader.makeReadOnly(options)
```

All three methods return a `Promise<undefined>`. `scan()` resolves as soon as listening has started, not when a tag is read; results arrive through `reading` (`NDEFReadingEvent` with `serialNumber` and `message`) and `readingerror` (fired when a tag is in range but reading it fails). `write()` and `makeReadOnly()` resolve once the tag has been written or locked, which may be seconds later when no tag is present yet. The interface is `[SecureContext, Exposed=Window]`, works only in the top-level browsing context, and is suspended while the page is not visible.

## Parameters

`scan()` and `makeReadOnly()` take one optional dictionary each; `write()` takes a message and an optional dictionary.

| Method | Parameter | Type | Required | Description |
|---|---|---|---|---|
| `scan()` | `options` | `NDEFScanOptions` `{ signal }` | No | `signal` (`AbortSignal`) stops listening and removes this reader from the active set. |
| `write()` | `message` | `NDEFMessageSource`: `DOMString`, `BufferSource`, or `NDEFMessageInit` | Yes | A string becomes one `"text"` record, a buffer one `"mime"` record of type `application/octet-stream`, and `NDEFMessageInit` supplies explicit records. |
| `write()` | `options` | `NDEFWriteOptions` `{ overwrite = true, signal }` | No | With `overwrite: false` the write is refused when the tag already holds NDEF records. `signal` aborts a pending write. |
| `makeReadOnly()` | `options` | `NDEFMakeReadOnlyOptions` `{ signal }` | No | `signal` aborts the pending lock. |

`NDEFMessageInit` has a single required member `records`, a `sequence<NDEFRecordInit>`.

| `NDEFRecordInit` member | Type | Required | Description |
|---|---|---|---|
| `recordType` | `USVString` | Yes | `"empty"`, `"text"`, `"url"`, `"smart-poster"`, `"mime"`, `"absolute-url"`, `"unknown"`, an external type such as `"example.com:shelf"`, or a local type such as `":act"` inside a smart poster. |
| `mediaType` | `USVString` | Only for `"mime"` | MIME type of the payload; must be absent for every other record type. |
| `id` | `USVString` | No | Record identifier; must be absent for `"empty"`. |
| `encoding` | `USVString` | No | For `"text"` only: `"utf-8"` (default), `"utf-16"`, `"utf-16be"`, or `"utf-16le"`. |
| `lang` | `USVString` | No | BCP 47 language tag for `"text"` records, for example `"en"`; defaults to the document language. |
| `data` | `any` | Depends on type | `DOMString` for `"text"`, `"url"`, and `"absolute-url"`; `BufferSource` for `"mime"` and `"unknown"`; `BufferSource` or `NDEFMessageInit` for external and `"smart-poster"` records; omitted for `"empty"`. |

Nesting is limited to 32 levels of `NDEFMessageInit`, and an external type name may not exceed 255 bytes.

## Exceptions

Checks that run before the promise is returned reject immediately; the rest happen after the user grants the `nfc` permission.

| Exception | Methods | Condition |
|---|---|---|
| `InvalidStateError` | all | The caller is not the active top-level browsing context (iframes are refused), or, for `scan()`, this reader is already scanning. |
| the signal's abort reason (`AbortError` by default) | all | `options.signal` was already aborted at call time, or is aborted while the operation is pending. |
| `TypeError` | `write()` | The message is invalid: empty `records`; nesting deeper than 32; a `mediaType` or `id` on a record type that forbids it; `data` of the wrong type for the `recordType`; a `"text"` `encoding` other than the four allowed; an unparsable `"url"`; an external type name over 255 bytes; or a `"smart-poster"` without exactly one URL record. |
| `NotAllowedError` | all | The user denied the `nfc` permission, or (`write()` with `overwrite: false`) the tag already carries NDEF records. |
| `NotSupportedError` | all | The device has no NFC adapter or the adapter is unreachable, the adapter does not support pushing data, or the tag does not expose NDEF technology and is not formattable. |
| `NotReadableError` | all | The browser is not allowed to use the adapter, for example when NFC is switched off in system settings. |
| `NetworkError` | `write()`, `makeReadOnly()` | The transfer to the tag or the lock operation failed, for instance because the tag moved away mid-write. |

`scan()` itself cannot report an unreadable tag: that arrives as a `readingerror` event on the reader, with no payload, so the handler should prompt the user to hold the tag still and try again.

:::observed
Chrome for Android rejects `new NDEFReader().scan()` called from a cross-origin iframe with `InvalidStateError: Web NFC can only be accessed in a top-level browsing context.`, a second `scan()` on the same reader with `InvalidStateError: A scan() operation is ongoing.`, and a call the user declines with `NotAllowedError: NFC permission request denied.`. The strings are defined in Chromium's [`ndef_reader.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/nfc/ndef_reader.cc) (chromium.googlesource.com).
:::

## Examples

Each example tests for `NDEFReader` in `window` first; on desktop browsers and on iOS the fallback branch runs, typically a QR code or manual entry that carries the same identifier.

### Scanning tags with a QR-code fallback

Start scanning from a tap so the permission prompt is tied to a gesture, decode `"text"` and `"url"` records, and show a QR scanner when Web NFC is missing. `scan()` resolves once listening starts; the actual reads come later through the `reading` event.

```js
async function startTagScan(onTag, showQrFallback) {
  if (!('NDEFReader' in window)) {
    showQrFallback();
    return;
  }

  const reader = new NDEFReader();
  reader.addEventListener('reading', ({ serialNumber, message }) => {
    const decoder = new TextDecoder();
    for (const record of message.records) {
      if (record.recordType === 'text') onTag({ serialNumber, text: decoder.decode(record.data) });
      if (record.recordType === 'url') onTag({ serialNumber, url: decoder.decode(record.data) });
    }
  });
  reader.addEventListener('readingerror', () => onTag({ error: 'Tag could not be read; hold it still and try again.' }));

  try {
    await reader.scan();
  } catch (err) {
    if (err.name === 'NotAllowedError') showQrFallback();
    else throw err;
  }
}
```

`record.data` is a `DataView`; `"text"` records carry their encoding in `record.encoding` and language in `record.lang`, so a UTF-16 tag needs `new TextDecoder(record.encoding)` instead of the default.

### Writing a URL only to blank tags, with a timeout

`overwrite: false` protects tags that already hold data, and an `AbortSignal` stops the pending write if no tag shows up within ten seconds. Without Web NFC the function returns `false` so the UI can offer to print the URL as a QR label instead.

```js
async function writeLabel(url) {
  if (!('NDEFReader' in window)) return false;

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 10_000);
  try {
    await new NDEFReader().write(
      { records: [{ recordType: 'url', data: url }] },
      { overwrite: false, signal: controller.signal },
    );
    return true;
  } catch (err) {
    if (err.name === 'AbortError') return false; // no tag within 10 s
    if (err.name === 'NotAllowedError') throw new Error('Tag already has content or NFC permission was denied');
    throw err;
  } finally {
    clearTimeout(timer);
  }
}
```

A tag that moves away during the transfer rejects with `NetworkError`; the records on it may then be partially written, so a retry should use `overwrite: true`.

### Writing a vendor record and locking the tag

External record types let an app store structured data under its own namespace. After writing, `makeReadOnly()` locks the tag so a visitor's phone cannot change it; the lock is irreversible, so the example asks for confirmation first.

```js
async function provisionShelfTag(shelfId, confirmLock) {
  if (!('NDEFReader' in window)) return 'unsupported';

  const reader = new NDEFReader();
  const payload = new TextEncoder().encode(JSON.stringify({ shelfId }));
  await reader.write({ records: [{ recordType: 'example.com:shelf', data: payload }] });

  if (!(await confirmLock())) return 'written';
  try {
    await reader.makeReadOnly();
    return 'locked';
  } catch (err) {
    if (err.name === 'NotSupportedError') return 'written-not-lockable';
    throw err;
  }
}
```

The external type `example.com:shelf` must be lower-case, use a domain you control, and stay under 255 bytes; reading it back yields `recordType === 'example.com:shelf'` with the same bytes in `record.data`.

## See also

- [Web Bluetooth API](/reference/capabilities/web-bluetooth/), for devices that advertise over radio instead of carrying a tag
- [WebHID API](/reference/capabilities/web-hid/)
- [WebUSB API](/reference/capabilities/web-usb/)
- [Web NFC: NDEFReader scan() method](https://w3c-cg.github.io/web-nfc/#dom-ndefreader-scan) (w3c-cg.github.io)
- [Web NFC: security policies](https://w3c-cg.github.io/web-nfc/#security-policies) (w3c-cg.github.io)
- [WebKit standards position: Web NFC](https://github.com/WebKit/standards-positions/issues/584) (github.com)
- [Interact with NFC devices on Chrome for Android](https://developer.chrome.com/docs/capabilities/nfc) (developer.chrome.com)