Receiving TapPony scans
TapPony sends one HTTP request per scan, shaped entirely by the profile you set up. Anything that accepts HTTP can receive it: a webhook service, an automation tool, or a few lines on your own server. This page covers what a request can carry and how to check it came from your phone.
Quick start
Create a profile from a preset or start blank, put your receiver's URL in, and tap Test in the editor. Test sends clearly fake sample values from a pretend NTAG215, so you can check the receiver without a tag and without polluting real data. When it answers with a 2xx, scan a real tag from the Scan tab.
The standard JSON body most presets start from:
{"uid":"{uid}","chip":"{chip}","tag_type":"{tag_type}","payload":"{payload}","timestamp":"{timestamp}","device":"{device_label}","seq":{seq}}Every value is escaped for where it lands, so a tag holding quotes, newlines or JSON of its own can't break out of the string it was put in. {seq} sits outside quotes and arrives as a JSON number.
Variables
Every variable is a string. When something doesn't apply to a tag, it is empty rather than an error. Content from the tag is capped at 8,192 characters.
The tag's identity
| Variable | What it holds |
|---|---|
{uid} | Canonical UID, uppercase hex, no separators. Same byte order on iPhone and Android. |
{uid_colon} | The UID as 04:A2:7F:1B:5E:80:00, the form NFC Tools and NXP TagInfo show. |
{uid_dec} | The UID as a decimal number, big-endian, the form many access-control readers print. |
{uid_rev} | The UID bytes reversed, the form many USB desktop readers report. |
{uid_len} | UID length in bytes: 4, 7, 8 or 10. |
{tag_type} | mifare_ultralight, mifare_desfire, mifare_plus, mifare_classic (Android only), iso15693, felica, iso7816 or unknown. |
{chip} | NTAG213, NTAG215, NTAG216, Ultralight EV1, DESFire EV1/EV2/EV3 and so on, or empty. |
{manufacturer} | NXP, STMicro, Infineon, Sony and so on, when the UID says. |
{signature} | The NXP originality signature of NTAG21x and Ultralight EV1 chips, hex. Empty when the chip has none. |
{counter} | The NTAG21x scan counter, when the tag has it switched on. |
{random_uid} | true when the tag shows a random ID on every read. Such a UID can't identify the tag. |
{tag_label} | The name you gave the tag in the Tags tab, or empty. |
{idm} {pmm} {system_code} | FeliCa identifiers. {uid} equals {idm} for FeliCa. |
{dsfid} {afi} {block_size} {block_count} | ISO 15693 system information. |
{pupi} {historical_bytes} {application_data} {atqa} {sak} | Lower-level ISO 14443 values. {atqa} and {sak} are Android only. |
What's written on the tag
| Variable | What it holds |
|---|---|
{payload} | The first NDEF record: text for a Text record, the full link for a URI record, Base64 otherwise. Empty with no NDEF. |
{ndef_text} | The first Text record anywhere on the tag. |
{ndef_uri} | The first URI record anywhere on the tag. |
{ndef_json} | Every record as a JSON array: tnf, type, id, payload in Base64, and text or uri where they decode. |
{ndef_raw} | The whole NDEF message in Base64. |
{ndef_count} | How many records the tag holds. |
{token} | The token of a TapPony launch link, when the tag carries one. |
The scan itself
| Variable | What it holds |
|---|---|
{timestamp} | Scan time, ISO 8601 UTC with milliseconds. |
{timestamp_local} {unix} {tz} | The scan time in the phone's time zone, as epoch seconds, and the zone name. |
{sent_at} | Send time. Differs from {timestamp} when a scan waited in the offline queue. |
{profile} {profile_id} | The profile's name and its stable id. |
{device_label} | A label you typed in Settings. Empty by default, never a hardware identifier. |
{platform} | ios or android. |
{nonce} | 16 random bytes as hex, fresh for every scan. |
{seq} | A counter per profile that goes up by one with every scan. |
{secret:NAME} | A secret saved in Settings, filled in at send time. Masked everywhere in the app. |
Templates and modifiers
{name} inserts a variable. A { only starts a placeholder when a lowercase letter or underscore follows it, so a JSON body is written as plain JSON with no escaping. {{ gives a literal brace in the rare spot where one has to sit before a lowercase letter.
Modifiers chain with a pipe and apply left to right: {uid|lower}, {uid|colon}, {uid|dec}, {uid|rev}, {payload|trim}, {payload|b64}, {ndef_text|url}, {timestamp|unix}, {payload|slice:0:32}, {tag_label|default:unnamed}. raw turns off automatic escaping where that's allowed. A JSON body that isn't valid JSON after filling in is never sent.
Escaping by where the placeholder sits: percent-encoding in the URL, form encoding in form fields, JSON string escaping inside quotes in a JSON body, and line breaks stripped from header values (always, even with raw).
Checking a request came from your phone
Turn on Sign requests with HMAC-SHA256 in the profile and save a secret named HMAC_KEY (or any name you pick) in Settings. Every request then carries three headers:
X-TapPony-Timestamp: 1790620205
X-TapPony-Nonce: 3f9c1d2e8b7a6f5e4d3c2b1a09f8e7d6
X-TapPony-Signature: sha256=<hex HMAC-SHA256(key, timestamp + "." + body)>The signature covers the timestamp, a dot, and the exact body bytes (an empty string when there is no body). It's the same shape GitHub and Stripe webhooks use. Compare in constant time and reject timestamps outside a window, five minutes here. Queued scans are signed when they are actually sent, so the window still holds for them.
<?php
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_TAPPONY_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_TAPPONY_SIGNATURE'] ?? '';
$key = 'the value of your HMAC_KEY secret';
$fresh = ctype_digit($ts) && abs(time() - (int)$ts) <= 300;
$good = hash_equals('sha256=' . hash_hmac('sha256', $ts . '.' . $body, $key), $sig);
if (!$fresh || !$good) {
http_response_code(401);
exit;
}import hashlib, hmac, time
def verify(body: bytes, ts: str, sig: str, key: bytes, tolerance: int = 300) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False
mac = hmac.new(key, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + mac, sig)const crypto = require('crypto');
function verify(rawBody, ts, sig, key, tolerance = 300) {
if (!/^\d+$/.test(ts || '') || Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false;
const mac = Buffer.from('sha256=' + crypto.createHmac('sha256', key).update(ts + '.' + rawBody).digest('hex'));
const got = Buffer.from(sig || '');
return mac.length === got.length && crypto.timingSafeEqual(mac, got);
}Verify against the raw body as it arrived, before any JSON parsing or re-serializing.
Answering the phone
Whatever the server returns shows on the result card with the status code. To show one piece of it large after each tap, set Show from the reply in the profile:
json:messagepicks a top-level field.json:data.items.0.namewalks into objects and arrays.header:X-Resultpicks a response header.
{"message": "Checkpoint 4 of 9", "data": {"items": [{"name": "Pump room"}]}}The profile can also set its own text for success and failure, using {message}, {status}, {uid} and {profile}, and have the result read out loud. Replies are only ever shown as text, never rendered as HTML or followed as links, and at most 200 characters are shown or spoken.
Offline queue and repeats
With Save and send later when offline on, a scan that gets no response at all waits on the phone and goes out in order once there's a connection, for up to 24 hours. A scan that got any HTTP answer, even an error, is never retried.
A queued scan keeps its {timestamp}, {nonce} and {seq}, and gets a fresh {sent_at}. If your receiver must not act twice, put {nonce} in the body and ignore one it has already seen. With signing on, the X-TapPony-Nonce header carries the same value. Leave the queue off for things like door locks, where a late request would be wrong.
PHP CSV receiver
For your own server: save this as tappony.php, point the PHP CSV receiver preset at it, and every scan becomes a row in a CSV file one folder above the script. Its reply works with json:message. Fill in $secret to require signed requests.
<?php
// TapPony CSV receiver. Appends one row per scan to a CSV file.
// Use with the "PHP CSV receiver" preset in TapPony. Save this file as
// tappony.php on your server and point the profile's URL at it.
//
// The CSV lands one folder above this script so the web server can't hand it
// out. Change $csv if your layout differs, and keep it out of the web root.
$csv = dirname(__DIR__) . '/tappony-scans.csv';
$secret = ''; // To require signed requests, put your HMAC_KEY value here and turn on signing in the profile.
$body = file_get_contents('php://input');
if ($secret !== '') {
$ts = $_SERVER['HTTP_X_TAPPONY_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_TAPPONY_SIGNATURE'] ?? '';
$ok = ctype_digit($ts) && abs(time() - (int)$ts) <= 300
&& hash_equals('sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret), $sig);
if (!$ok) {
http_response_code(401);
exit;
}
}
header('Content-Type: application/json');
$scan = json_decode($body, true);
if (!is_array($scan) || empty($scan['uid'])) {
http_response_code(400);
echo json_encode(['message' => 'Not a TapPony scan']);
exit;
}
$cols = ['timestamp', 'uid', 'chip', 'tag_type', 'payload', 'device', 'seq'];
$row = [];
foreach ($cols as $c) {
$v = (string)($scan[$c] ?? '');
// Keep spreadsheet apps from running a tag's content as a formula.
if ($v !== '' && strpbrk($v[0], "=+-@\t\r") !== false) {
$v = "'" . $v;
}
$row[] = $v;
}
$fh = fopen($csv, 'a');
if ($fh === false) {
http_response_code(500);
echo json_encode(['message' => 'Could not open the CSV file']);
exit;
}
flock($fh, LOCK_EX);
clearstatcache(true, $csv);
if (filesize($csv) === 0) {
fputcsv($fh, $cols);
}
fputcsv($fh, $row);
flock($fh, LOCK_UN);
fclose($fh);
echo json_encode(['message' => 'Logged ' . $scan['uid']]);
Home Assistant
Create an automation with a Webhook trigger, then use the Home Assistant webhook preset with its ID in the URL. The scan's values are available as trigger.json.uid, trigger.json.payload and so on.
alias: Front door tag
trigger:
- platform: webhook
webhook_id: tappony-front-door
allowed_methods: [POST]
local_only: true
action:
- service: light.toggle
target:
entity_id: light.hallwayThe Home Assistant tag preset instead fires a tag_scanned event with the UID as tag_id, so an existing badge works as a Home Assistant tag. It needs a long-lived access token saved as the secret HA_TOKEN.
Launch links
Tags you write from the Tags tab can carry a launch link: https://tappony.app/t/?k= followed by a random 22-character token. Tapping such a tag opens TapPony and sends it, even when the app is closed. The token is {token} in your templates.
Only the phone that wrote the tag knows its token, so the same tag sends nothing from anyone else's phone. A phone without TapPony just opens a short page on this site. The site doesn't log the token, the visitor's address, or anything else about that visit.
Local network
Anything on the internet must be https://. Plain http:// is allowed only to addresses on your own network (private IPv4 ranges, IPv6 local addresses, .local and .home.arpa names, and single-word host names), and only after you turn it on in that profile. Anyone on the same Wi-Fi can read a plain HTTP request, secrets included.
Redirects aren't followed unless the profile allows it, and then only to the same host over the same scheme.