NFC Mobile SDK
There is no TAG IT NFC package to install on iOS or Android. This page documents what a native app actually does with a TAG IT chip: read the URL it emits using the platform NFC API, then verify that tap server-side.
There is no CocoaPod named TagItNFC, no Swift Package Manager package, no Maven or Gradle artifact network.tagit:nfc-sdk, and no class called TagItNFCScanner. The repository TAG-IT-NETWORK/tagit-nfc-ios does not exist either. Checked against the public registries on 2026-07-26: CocoaPods trunk and Maven Central both return HTTP 404 for those names, and so does npm for @tagit/sdk.
Everything below uses Apple's Core NFC and Android's NfcAdapter directly, with no TAG IT dependency — because there is nothing to depend on. That is not a workaround: a TAG IT chip emits a plain HTTPS URL, so the platform NFC stack is all a reader needs.
What exists today
TAG IT is deployed to the Base Sepolia testnet (chain ID 84532) and the contracts are unaudited. Nothing on this page should be treated as production infrastructure.
| Component | What it is | Status |
|---|---|---|
| ORACULAR app | First-party scanner app (Expo / React Native). Tap a chip, verify, view the asset. | Internal builds only — not on the App Store or Google Play, and not distributed as a library |
verify.tagit.network/api/verify |
Server-side verification of an NTAG 424 DNA SUN tap | Live |
verify.tagit.network/api/dpp/... |
Digital Product Passport (W3C Verifiable Credential) for a GS1 Digital Link tap | Live |
| TAGITCore contract | Direct on-chain read of asset owner, timestamp and lifecycle state | Live on Base Sepolia |
| iOS / Android NFC SDK | Installable CocoaPod, SPM package or Gradle artifact from TAG IT | Does not exist |
Device requirements
These are platform constraints, not TAG IT ones:
- iOS 13+ — NDEF reading via Core NFC (
NFCNDEFReaderSession). Requires iPhone 7 or later. - Android 5.0+ (API level 21) — reading via
android.nfc.NfcAdapter, on hardware that actually has an NFC controller.
iPhone XS and later read NDEF tags in the background with no app open or installed. Because a TAG IT chip carries an ordinary HTTPS URL, that background tap simply opens the verifier in Safari — which is the fallback path for every consumer who does not have your app.
Why the tap is verified server-side
TAG IT uses the NXP NTAG 424 DNA chip in SUN (Secure Unique NFC) mode. On every tap the chip rewrites its own NDEF URL and appends two query parameters:
picc— 32 hex characters. The chip's UID and its monotonic tap counter, AES-encrypted with the chip's SDM key.cmac— 16 hex characters. A truncated AES-CMAC over that message, computed by the chip itself.
A phone reading the tag receives ciphertext and a MAC and nothing else. It cannot decrypt the PICC or validate the CMAC on-device, because both require the SDM key — and shipping that key inside an app binary would let any attacker mint valid taps for every chip in the fleet. So the app forwards picc and cmac to the verifier, which holds the key server-side, checks the CMAC, rejects replays using the tap counter, and resolves the on-chain twin.
Offline SUN verification is not a missing feature — it is cryptographically impossible under this design without putting the SDM key on the device. An app with no connectivity can show a cached earlier result, but it cannot establish that a fresh tap is genuine.
The tap → verify flow
- Read the NDEF URI record from the chip with the platform NFC API.
- Pull
piccandcmacout of that URL's query string. GET https://verify.tagit.network/api/verify?picc=<32 hex>&cmac=<16 hex>- Render the JSON response.
Chips are personalized to a SUN landing URL carrying those parameters, for example:
https://verify.tagit.network/sun?picc=<32 hex>&cmac=<16 hex>
You can exercise the endpoint right now. Parameters that are not a genuine tap return HTTP 200 with a structured rejection:
curl -s "https://verify.tagit.network/api/verify?picc=00000000000000000000000000000000&cmac=0000000000000000"
{
"verified": false,
"reason": "unexpected PICC tag 0xba",
"chain": { "id": 84532, "name": "Base Sepolia" }
}
There is no API key and no registration step: the endpoint is public because the chip's CMAC is the credential. Producing "verified": true requires a physical chip — a valid picc/cmac pair cannot be synthesized without one.
Permissions & entitlements
iOS (Info.plist)
<key>NFCReaderUsageDescription</key>
<string>Used to verify the authenticity of your TAG IT-chipped items.</string>
You must also enable the Near Field Communication Tag Reading capability in your target's Signing & Capabilities tab. The com.apple.developer.nfc.readersession.iso7816.select-identifiers entitlement is only needed if you drive the chip with raw APDUs via NFCTagReaderSession; the SUN flow does not require it, because everything you need is already in the NDEF record.
Android (AndroidManifest.xml)
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.nfc" android:required="true" />
android:required="true" hides your app on Google Play from devices without NFC hardware. Set it to false and check NfcAdapter.getDefaultAdapter(context) != null at runtime if the rest of your app should still install there.
iOS — Core NFC
Read the URI record with NFCNDEFReaderSession. No third-party dependency is involved:
import CoreNFC
enum TapError: Error { case notASunURL }
final class TapReader: NSObject, NFCNDEFReaderSessionDelegate {
private var session: NFCNDEFReaderSession?
private var onURL: ((URL) -> Void)?
func begin(onURL: @escaping (URL) -> Void) {
guard NFCNDEFReaderSession.readingAvailable else { return }
self.onURL = onURL
let session = NFCNDEFReaderSession(
delegate: self, queue: nil, invalidateAfterFirstRead: true
)
session.alertMessage = "Hold your iPhone near the TAG IT chip"
session.begin()
self.session = session
}
// A chip in SUN mode carries one URI record: the verifier URL, with a
// picc/cmac pair the chip regenerated for this specific tap.
func readerSession(_ session: NFCNDEFReaderSession,
didDetectNDEFs messages: [NFCNDEFMessage]) {
for message in messages {
for record in message.records {
if let url = record.wellKnownTypeURIPayload() {
session.alertMessage = "Chip read"
onURL?(url)
return
}
}
}
session.invalidate(errorMessage: "No URL record on this chip")
}
// Also fires on user cancel and on the 60-second session timeout.
func readerSession(_ session: NFCNDEFReaderSession,
didInvalidateWithError error: Error) {
self.session = nil
}
}
Then hand the URL to the verifier. This is the whole network integration — one GET, no headers beyond Accept:
// URLSession.data(for:) requires iOS 15+.
func verifyTap(_ tagURL: URL) async throws -> Data {
guard
let tapped = URLComponents(url: tagURL, resolvingAgainstBaseURL: false),
let picc = tapped.queryItems?.first(where: { $0.name == "picc" })?.value,
let cmac = tapped.queryItems?.first(where: { $0.name == "cmac" })?.value
else { throw TapError.notASunURL }
var endpoint = URLComponents(string: "https://verify.tagit.network/api/verify")!
endpoint.queryItems = [
URLQueryItem(name: "picc", value: picc),
URLQueryItem(name: "cmac", value: cmac),
]
var request = URLRequest(url: endpoint.url!)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, _) = try await URLSession.shared.data(for: request)
return data // decode into your own Codable type — see the fields below
}
Android — NfcAdapter
Reader mode keeps the tap inside your Activity instead of firing an intent, which is what you want behind a deliberate "scan" button:
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.Ndef
import androidx.appcompat.app.AppCompatActivity
class ScanActivity : AppCompatActivity() {
private val nfcAdapter: NfcAdapter? by lazy { NfcAdapter.getDefaultAdapter(this) }
override fun onResume() {
super.onResume()
// The callback runs on a binder thread, not the main thread.
nfcAdapter?.enableReaderMode(
this,
{ tag -> readSunUrl(tag)?.let { url -> onTagUrl(url) } },
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS,
null,
)
}
override fun onPause() {
super.onPause()
nfcAdapter?.disableReaderMode(this)
}
/** NTAG 424 DNA in SUN mode exposes a single NDEF URI record. */
private fun readSunUrl(tag: Tag): String? {
val ndef = Ndef.get(tag) ?: return null // not an NDEF tag
// Cached at discovery — a plain read needs no connect().
val message = ndef.cachedNdefMessage ?: return null
return message.records.firstNotNullOfOrNull { it.toUri()?.toString() }
}
/** Your handler: send the URL to verifyTap() below, then render the result. */
private fun onTagUrl(url: String) { /* ... */ }
}
The verification call, with no HTTP library beyond the JDK:
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
suspend fun verifyTap(tagUrl: String): String = withContext(Dispatchers.IO) {
val tapped = Uri.parse(tagUrl)
val picc = tapped.getQueryParameter("picc")
val cmac = tapped.getQueryParameter("cmac")
require(picc != null && cmac != null) { "Not a SUN URL: missing picc or cmac" }
val endpoint = Uri.parse("https://verify.tagit.network/api/verify")
.buildUpon()
.appendQueryParameter("picc", picc)
.appendQueryParameter("cmac", cmac)
.build()
.toString()
val connection = URL(endpoint).openConnection() as HttpURLConnection
connection.setRequestProperty("Accept", "application/json")
try {
connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
}
}
What the verifier returns
These are the fields the first-party ORACULAR app reads from a verification response (see src/services/sunVerify.ts in the tagit-mobile repository). Model your decoder on this set rather than assuming anything wider:
| Field | Type | Meaning |
|---|---|---|
verified | boolean | Whether the CMAC checked out against a known chip |
reason | string | Why verification failed — present when verified is false |
uid | string | Chip UID, recovered server-side by decrypting the PICC |
tapCounter | number | The chip's monotonic tap counter, used for replay rejection |
chain.id / chain.name | number / string | Chain the twin was resolved on — 84532 / Base Sepolia |
asset.tokenId | string | Token ID of the on-chain digital twin |
asset.stateCode | number | Lifecycle state as a number — see the table below |
asset.lifecycleState | string | The same state as a name, e.g. CLAIMED |
asset.owner | string | Current owner address |
asset.timestamp | number | Unix seconds of the last state change |
Lifecycle states
asset.stateCode — and the state byte returned by the contract — map to the seven-state asset lifecycle:
| Value | State | Meaning |
|---|---|---|
| 0 | NONE | Token does not exist |
| 1 | MINTED | Digital twin created, no chip bound yet |
| 2 | BOUND | NFC chip cryptographically linked to the twin |
| 3 | ACTIVATED | Passed QA, ready for market |
| 4 | CLAIMED | Owned by an end consumer |
| 5 | FLAGGED | Lost, stolen or under investigation |
| 6 | RECYCLED | End of life, terminal |
Reading asset state without the verifier
If you already hold a token ID — from a verified tap, a deep link or your own records — you can read its state straight from the contract with no TAG IT service in the path. TAGITCore is deployed on Base Sepolia at 0x3aDc7EFDb58Ae85483eFf5D4966D916185f31d1D, and getAsset(uint256) is a plain eth_call, so URLSession or HttpURLConnection is enough — no wallet library required.
The calldata is the selector 0xeac8f5b8 followed by the token ID padded to 32 bytes. This request and its response were executed against Base Sepolia on 2026-07-26:
curl -s -X POST https://sepolia.base.org \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
"to":"0x3aDc7EFDb58Ae85483eFf5D4966D916185f31d1D",
"data":"0xeac8f5b80000000000000000000000000000000000000000000000000000000000000032"
},"latest"]}'
{"jsonrpc":"2.0","result":"0x000000000000000000000000458b4d0c3a55006965fd13d6af7b8509de51cb3d000000000000000000000000000000000000000000000000000000006a596fa8000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","id":1}
The return value is (address assetOwner, uint64 timestamp, uint8 state, uint8 flags, uint16 reserved), one 32-byte word each. Decoding the result above, for token ID 50:
| Word | Field | Value |
|---|---|---|
| 1 | assetOwner | 0x458B4d0c3a55006965Fd13D6af7B8509De51Cb3D |
| 2 | timestamp | 0x6a596fa8 = 1784246184 |
| 3 | state | 4 — CLAIMED |
| 4 | flags | 0 |
| 5 | reserved | 0 |
Note what this read does not tell you: that a physical chip was present. Only a server-verified tap establishes that. An on-chain read describes the state of the digital twin, and says nothing about the object in the customer's hand.
Seven of the eight state-changing functions — mint, bindTag, activate, claim, flag, resolve and recycle — are gated on a BIDGES capability badge, so an ordinary wallet cannot call them. The exception is transferAsset, the peer-to-peer resale path: it is owner-gated rather than capability-gated, so whoever currently owns a CLAIMED asset may call it. Plain ERC-721 transferFrom is blocked by the contract's _update() override, which makes transferAsset the only consumer transfer route.
None of that changes what the code on this page does. Reading is a bare eth_call; writing needs a signing wallet, which Core NFC and NfcAdapter do not provide. A scanner built from this page is a read-and-verify client.
React Native
The ORACULAR app is Expo + React Native and reads chips with the third-party react-native-nfc-manager package, then verifies through the same endpoint documented above. That path, including the crypto polyfills React Native needs before viem will run, is written up on the Mobile SDKs page.
Testing & troubleshooting
NFC does not work in the iOS Simulator, the Android emulator or Expo Go. Nor can a tap be faked in software: a valid picc/cmac pair can only come from a chip, so end-to-end testing needs real hardware.
- Session times out after 60 seconds (iOS) —
NFCNDEFReaderSessioninvalidates itself; start a new session rather than reusing the old one. readingAvailableis false — the device is older than iPhone 7, or the Near Field Communication Tag Reading capability is missing from the build.NfcAdapter.getDefaultAdapter()returns null — no NFC hardware. If it is non-null butisEnabled()is false, NFC is switched off; send the user toSettings.ACTION_NFC_SETTINGS.- No URI record found — the tag is not an NDEF-formatted TAG IT chip. NTAG 424 DNA is the only chip TAG IT uses.
- Missing
piccorcmac— the URL came from a chip that was not personalized for SUN, or from a static copy of a URL. A static copy always fails verification, which is the point. verified: falsewith areason— the tap was read but the CMAC did not check out. Thereasonstring is the server's own diagnostic; surface it verbatim rather than remapping it to error codes of your own, which will drift.
Not available yet
Listed explicitly so that nobody plans around them:
- An installable TAG IT NFC package — no CocoaPod, Swift Package, Maven/Gradle artifact, React Native package or Flutter plugin. The JavaScript
@tagit/sdkis not on npm either, and in any case it is an agent identity / reputation / validation client with WTag and Voucher helpers — it does not read chips, register products or transfer ownership. - Prebuilt UI components — there is no scanner view, verification card or history list to drop into your app. Build your own on top of the response fields above.
- Offline verification — impossible without shipping the SDM key, as described above.
- Writing or personalizing chips from a phone — chips are personalized with desk-reader tooling, and every lifecycle write on-chain requires a capability badge.
- A public app build — ORACULAR is not on the App Store or Google Play.
- Support for other chips — nothing here applies to NTAG 213/215/216, DESFire or ISO 15693 tags.
Contracts are deployed to Base Sepolia (84532) and have not completed a third-party audit. The token ID, address and responses shown here are testnet artifacts and may be reset. Do not build production or custody-bearing flows on them.
Deployed addresses are listed on the Contract Addresses page. For anything else, ask in our Discord community — if a capability you need is missing, tell us rather than assuming it is coming.