Authentication
Secure your API requests with Bearer tokens
All Tapped API requests require a Bearer token for authentication. Tokens use the prefix ch_live_ and can be scoped to specific permissions.
Generating an API Key
API keys are generated in the Tapped dashboard or programmatically via the API.
Request Body:
| Parameter | Type | Description |
|---|---|---|
name |
string | Required. A descriptive name for the key (e.g., "Shopify Integration") |
scopes |
array | Required. Array of scopes: read, write, admin |
curl -X POST https://api.tappedtags.com/v1/keys \
-H "Authorization: Bearer ch_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Zapier Integration",
"scopes": ["read", "write"]
}'
const response = await fetch('https://api.tappedtags.com/v1/keys', {
method: 'POST',
headers: {
'Authorization': 'Bearer ch_live_YOUR_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Zapier Integration',
scopes: ['read', 'write']
})
});
const data = await response.json();
console.log(data.data.key); // Save this! Shown only once
{
"data": {
"id": "key_abc123",
"name": "Zapier Integration",
"key": "ch_live_xxxxxxxxxxxxxxxxxxxxx",
"scopes": ["read", "write"],
"created_at": "2026-04-11T10:30:00Z"
}
}
Scopes
| Scope | Permissions |
|---|---|
read |
View tags, actions, sequences, schedules, routes, groups, scans, and analytics |
write |
Create, update, and delete tags, actions, sequences, schedules, routes, and groups. Includes read permissions. |
admin |
Manage API keys. Includes read and write permissions. Only available to account admins. |
Using the Token
Include your API key in the Authorization header of every request:
Authorization: Bearer ch_live_xxxxxxxxxxxxxxxxxxxxx
Token Security
- Never share your token in public code, GitHub, or client-side applications
- Rotate keys periodically for security
- Use minimal scopes — only grant permissions you need
- Revoke immediately if a key is compromised
- Store securely in environment variables or a secrets manager
Your First Request
Get up and running in 5 minutes
Step 1: Generate an API Key
- Log in to app.tappedtags.com
- Navigate to Profile → API Keys
- Click Create API Key
- Name it (e.g., "Local Development")
- Select scopes:
readandwrite - Save the key in a
.envfile or secure location
Step 2: Make Your First Request
Let's fetch your list of tags:
curl https://api.tappedtags.com/v1/porters \ -H "Authorization: Bearer ch_live_YOUR_KEY"
const apiKey = process.env.TAPPED_API_KEY;
const response = await fetch('https://api.tappedtags.com/v1/porters', {
headers: {
'Authorization': `Bearer ${apiKey}`
}
});
const { data } = await response.json();
console.log(data); // Array of your tags
import requests
import os
api_key = os.getenv('TAPPED_API_KEY')
headers = {'Authorization': f'Bearer {api_key}'}
response = requests.get(
'https://api.tappedtags.com/v1/porters',
headers=headers
)
data = response.json()
print(data['data']) # Array of your tags
Step 3: Understand the Response
{
"data": [
{
"id": "uuid",
"identifier": "ABCD1",
"nickname": "Conference Badge",
"group_id": "uuid",
"individual_action_id": "uuid",
"use_schedule": false,
"scan_count": 42,
"metadata": { "custom_field": "value" }
}
],
"count": 1
}
Common First Requests
Create a Tag
const tag = await fetch('https://api.tappedtags.com/v1/porters', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
identifier: 'DEMO1',
nickname: 'Demo Tag',
metadata: { department: 'sales' }
})
});
const { data } = await tag.json();
console.log(data.id); // Use this ID for assignments
Assign an Action to a Tag
// Assuming you have an action ID
const actionId = 'action-uuid-here';
const porterId = 'tag-id-from-previous-step';
const assign = await fetch(
`https://api.tappedtags.com/v1/porters/${porterId}/assign`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'action',
id: actionId
})
}
);
const { data } = await assign.json();
console.log('Assignment successful:', data);
List All Actions
const actions = await fetch('https://api.tappedtags.com/v1/actions', {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
const { data } = await actions.json();
console.log(data); // Array of your actions
Best Practices
Patterns for production-ready integrations
Error Handling
Always check the response status and handle errors gracefully:
async function callApi(endpoint, options = {}) {
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...options.headers
},
...options
});
const body = await response.json();
if (!response.ok) {
// Handle errors
const error = body.error || {};
throw new Error(
`API Error ${error.status}: ${error.message}`
);
}
return body.data;
}
// Usage
try {
const tags = await callApi('https://api.tappedtags.com/v1/porters');
console.log('Tags:', tags);
} catch (err) {
console.error('Failed to fetch tags:', err.message);
// Retry logic, alerting, etc.
}
Pagination
For endpoints that return large datasets, use pagination to avoid timeouts:
async function getAllScans(porterId, pageSize = 100) {
const allScans = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
const scans = await callApi(
`https://api.tappedtags.com/v1/porters/${porterId}/scans` +
`?limit=${pageSize}&offset=${offset}`
);
allScans.push(...scans);
// Stop if we got fewer results than requested
hasMore = scans.length === pageSize;
offset += pageSize;
}
return allScans;
}
const allScans = await getAllScans('tag-id');
console.log(`Got ${allScans.length} scans`);
Bulk Operations
When working with multiple tags, use bulk endpoints to reduce API calls:
// INEFFICIENT: 100 API calls
async function slowAssign(tagIds, actionId) {
for (const tagId of tagIds) {
await callApi(`https://api.tappedtags.com/v1/porters/${tagId}/assign`, {
method: 'POST',
body: JSON.stringify({ type: 'action', id: actionId })
});
}
}
// EFFICIENT: 1 API call
async function fastAssign(tagIds, actionId) {
return await callApi(
'https://api.tappedtags.com/v1/porters/bulk/assign',
{
method: 'PATCH',
body: JSON.stringify({
porter_ids: tagIds,
type: 'action',
id: actionId
})
}
);
}
// Tag up to 500 at once
await fastAssign(['tag-1', 'tag-2', ..., 'tag-500'], actionId);
Retry Logic with Exponential Backoff
async function callApiWithRetry(
endpoint,
options = {},
maxRetries = 3
) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...options.headers
},
...options
});
const body = await response.json();
if (!response.ok) {
const error = body.error || {};
// Don't retry on 400, 401, 403, 404
if ([400, 401, 403, 404].includes(error.status)) {
throw new Error(`API Error: ${error.message}`);
}
// Retry on 429 (rate limited), 500, 503
if (![429, 500, 503].includes(error.status)) {
throw new Error(`API Error: ${error.message}`);
}
lastError = new Error(
`API Error ${error.status}: ${error.message}`
);
} else {
return body.data;
}
} catch (err) {
lastError = err;
}
// Wait before retrying: 1s, 2s, 4s
if (attempt < maxRetries - 1) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise(r => setTimeout(r, delay));
}
}
throw lastError;
}
Webhook Validation
When receiving webhook notifications, validate the signature:
const crypto = require('crypto');
function validateWebhookSignature(payload, signature, secret) {
// Create HMAC-SHA256 hash
const hash = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
// Use constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(hash),
Buffer.from(signature)
);
}
// In your webhook handler
app.post('/webhook', (req, res) => {
const signature = req.headers['x-tapped-signature'];
const secret = process.env.WEBHOOK_SECRET;
if (!validateWebhookSignature(req.body, signature, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook...
res.json({ ok: true });
});
Environment Variables
Never hardcode API keys. Use environment variables:
TAPPED_API_KEY=ch_live_xxxxxxxxxxxxxxxxxxxxx WEBHOOK_SECRET=whk_xxxxxxxxxxxxxxxxxxxxx
require('dotenv').config();
const apiKey = process.env.TAPPED_API_KEY;
if (!apiKey) {
throw new Error('TAPPED_API_KEY environment variable not set');
}
Data Types
Common data structures used throughout the API
Tag (Porter)
| Field | Type | Description |
|---|---|---|
id |
uuid | Unique tag identifier |
identifier |
string | Human-readable tag code (e.g., ABCD1). Used in scan URLs: tapd.ink/T-ABCD1 |
nickname |
string | Custom display name for the tag |
group_id |
uuid | null | Parent group UUID, if any |
individual_action_id |
uuid | null | Direct action assignment (takes precedence) |
use_schedule |
boolean | Whether to use assigned schedule |
use_sequence |
boolean | Whether to use assigned sequence |
use_route |
boolean | Whether to use assigned route |
scan_count |
integer | Total number of scans (read-only) |
metadata |
object | Custom key-value pairs for integration (CRM, webhooks, etc.) |
created_at |
timestamp | Creation timestamp (read-only) |
Action Types — Summary
Every action has a type field (lowercase string) and a primary_data JSON object whose shape depends on the type. The summary below is for quick reference; for the full primary_data schema of each type, see Action Type Schemas.
| Type | What scanner sees | Tier |
|---|---|---|
url | 302 redirect to a destination URL (optional query-param injection) | Starter |
phone | 302 redirect to tel: URI — phone dialer opens | Starter |
sms | 302 redirect to sms: URI — messaging app opens with pre-filled body | Starter |
email | 302 redirect to mailto: URI — email composer opens with pre-filled subject/body | Starter |
wifi | Branded WiFi share page with Android one-tap join + iPhone step-by-step | Starter |
vcard | Branded contact card page (name, photo, title, all contact methods, social links) | Essential |
form | Branded lead-capture form with definable fields; submission saved to form_submissions | Essential |
menu | Branded button grid; each button links to another action or an inline target | Starter |
html | Raw HTML page (no Tapped chrome, full creative control) | Starter |
message | Branded message page (rich HTML body from Quill editor) | Starter |
ab_split | Resolves to variant A or B by weighted random; the chosen action runs | Professional |
Action Type Schemas
Complete primary_data structure for every action type
Every action accepted by Create Action and Update Action requires a type string and a primary_data object. The structure of primary_data differs per type and is documented below. Each section shows the exact field schema, an example JSON, variable-interpolation support, and tier requirements.
URL Action
Type: "url" · Tier: Starter (basic URL); Essential for URL Parameters
primary_data fields:
| Field | Type | Description |
|---|---|---|
url | string | Required. Destination URL (must include scheme). |
url_params | array | Optional (Essential+). Array of query-parameter rules appended to the URL on each scan. Each entry: { "name": string, "type": "literal" \| "variable", "value": string }. When type is "variable", value is a scan-context key (see Variable Interpolation); when "literal", value is the raw string. |
{
"url": "https://example.com/landing",
"url_params": [
{ "name": "src", "type": "literal", "value": "tapped" },
{ "name": "tag_id", "type": "variable", "value": "identifier" },
{ "name": "customer", "type": "variable", "value": "customer_label" }
]
}
Worker behavior: 302 redirect. If url_params is set, parameters are appended to the destination URL as query string at scan time.
Phone Action
Type: "phone" · Tier: Starter
primary_data fields:
| Field | Type | Description |
|---|---|---|
number | string | Required. Phone number, ideally with country code (e.g. +15551234567). Whitespace is stripped at scan time. |
{ "number": "+15551234567" }
Worker behavior: 302 redirect to tel:<number>. Phone dialer opens with the number pre-filled (user still needs to tap call).
SMS Action
Type: "sms" · Tier: Starter
primary_data fields:
| Field | Type | Description |
|---|---|---|
to | string | Required. Recipient phone number. Whitespace stripped at scan time. |
body | string | Optional. Pre-filled message text. Supports {{variable}} interpolation. |
{
"to": "+15551234567",
"body": "Hi! I scanned tag {{identifier}} and want more info."
}
Worker behavior: 302 redirect to sms:<to>?body=<url-encoded body>. Messaging app opens with recipient and body pre-filled.
Email Action
Type: "email" · Tier: Starter
primary_data fields:
| Field | Type | Description |
|---|---|---|
to | string | Required. Recipient email address. |
subject | string | Optional. Pre-filled subject line. Supports {{variable}} interpolation. |
body | string | Optional. Pre-filled body text. Supports {{variable}} interpolation. |
{
"to": "sales@acme.com",
"subject": "Inquiry from tag {{nickname}}",
"body": "Hi, I scanned {{nickname}} and would like to learn more."
}
Worker behavior: 302 redirect to mailto:<to>?subject=...&body=.... Email composer opens with fields pre-filled.
WiFi Action
Type: "wifi" · Tier: Starter
primary_data fields:
| Field | Type | Description |
|---|---|---|
ssid | string | Required. Network SSID (name). |
password | string | Required (unless security: "nopass"). Plain-text password on create. Stored encrypted at rest via AES-GCM with PBKDF2 key derivation; decrypted at scan time. On update, an empty value preserves the existing password. |
security | string | Optional. One of "WPA" (default), "WEP", or "nopass". |
{
"ssid": "GuestWiFi",
"password": "hunter2!",
"security": "WPA"
}
Worker behavior: Renders a branded WiFi share page. Android scanners see a one-tap "Connect" button using the WIFI: URI scheme. iPhone scanners see a polished 3-step copy → settings → paste guide. Desktop visitors see the credentials with a copy button.
vCard (Contact Card) Action
Type: "vcard" · Tier: Essential
primary_data fields: (large, nested structure)
| Field | Type | Description |
|---|---|---|
prefix, first_name, middle_name, last_name, suffix, nickname | string | Name fields. first_name and last_name are recommended. |
job_title, department, company_name, company_headline, bio | string | Professional info displayed beneath the name. |
profile_picture_url, company_logo_url, banner_image_url | string | Image URLs. Upload via POST /api/upload-asset first (Tapped's R2-hosted assets), then reference the returned URL. |
phones | array | Array of { "type": string, "number": string, "ext"?: string }. type is free-text label like "work", "mobile", "fax". |
emails | array | Array of { "type": string, "address": string }. |
websites | array | Array of { "type": string, "url": string }. |
addresses | array | Array of { "type": string, "street": string, "city": string, "state": string, "zip": string, "country": string }. Typically a single entry. |
social_links | object | Map of platform → handle/URL. Keys supported: linkedin, twitter, instagram, facebook, youtube, tiktok, github, snapchat, pinterest, threads. |
birthday, anniversary | string | Date strings (ISO date or human-readable). |
theme | object | { "primary_color": "#hex", "background_color": "#hex", "font_family": string }. Per-action theming. Overridden by brand themes if applied. |
{
"first_name": "Jane",
"last_name": "Smith",
"job_title": "Sales Director",
"company_name": "Acme Corp",
"phones": [
{ "type": "work", "number": "+15551234567" },
{ "type": "mobile", "number": "+15559998888" }
],
"emails": [
{ "type": "work", "address": "jane@acme.com" }
],
"websites": [
{ "type": "work", "url": "https://acme.com" }
],
"social_links": { "linkedin": "jane-smith" },
"theme": { "primary_color": "#0071e3", "background_color": "#ffffff", "font_family": "Inter" }
}
Worker behavior: Renders a branded contact-card page. Includes "Save to Contacts" button generating a vCard 4.0 file for the scanner's address book. Respects brand theme cascade.
Form (Lead Capture) Action
Type: "form" · Tier: Essential
primary_data fields:
| Field | Type | Description |
|---|---|---|
title | string | Form title displayed at top of page. |
description | string | Optional. Subtitle / intro text shown below title. |
button_label | string | Optional. Submit button text. Default: "Submit". |
fields | array | Required. Form field definitions (see below). |
redirect_url | string \| null | Optional. URL to redirect to after submit. If null/empty, the success_message is shown instead. |
success_message | string | Optional. Message shown after successful submit when no redirect_url. Default: "Thanks! We'll be in touch." |
Field schema (each entry in fields):
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier (e.g. "f_a1b2c3"). Used as the key in form submission storage. |
type | string | One of: "text", "email", "phone", "number", "textarea", "select", "radio", "checkbox". |
label | string | Label displayed to scanner (e.g. "Email Address"). |
required | boolean | Whether the field must be filled before submit. |
options | array | Array of strings. Required when type is "select", "radio", or "checkbox". Each string is one option label. |
{
"title": "Join Our Waitlist",
"description": "Be the first to know when we launch.",
"button_label": "Sign Up",
"fields": [
{ "id": "f_name", "type": "text", "label": "Name", "required": true },
{ "id": "f_email", "type": "email", "label": "Email", "required": true },
{ "id": "f_role", "type": "select", "label": "Role", "required": false, "options": ["Founder", "PM", "Engineer", "Other"] },
{ "id": "f_notes", "type": "textarea", "label": "Anything else?", "required": false }
],
"redirect_url": null,
"success_message": "Thanks! We'll be in touch."
}
Worker behavior: GET renders the branded form page. POST handles form submission — saves to form_submissions with the porter ID + collected fields, fires both legacy and Webhook Profile webhooks (see Webhooks), then either redirects to redirect_url or renders the success page.
Menu Action
Type: "menu" · Tier: Starter
primary_data fields:
| Field | Type | Description |
|---|---|---|
title | string | Menu title shown at top of page. |
subtitle | string | Optional. Subtitle shown below title. |
buttons | array | Required. Up to 8 buttons. See schema below. |
Button schema (each entry in buttons):
| Field | Type | Description |
|---|---|---|
label | string | Required. Button text. |
icon | string | Optional. Material Icon name (e.g. "phone", "wifi"). |
icon_color | string | Optional. CSS color string (e.g. "#0071e3"). |
icon_filled | boolean | Optional. Use filled icon variant. |
style | string | Optional. Visual style hint. |
action_id | uuid | Required (one of action_id OR inline). UUID of a library action this button links to. Cannot reference menu or ab_split actions (prevents loops). |
inline | object | Required (one of action_id OR inline). Inline target object: { "type": "url" \| "phone" \| "email", "value": string }. |
{
"title": "Welcome to the Lake House",
"subtitle": "What can we help you with?",
"buttons": [
{ "label": "Connect to WiFi", "icon": "wifi", "icon_color": "#0071e3", "action_id": "a1b2c3d4-..." },
{ "label": "Local Restaurants", "icon": "restaurant", "inline": { "type": "url", "value": "https://maps.example.com" } },
{ "label": "Call Host", "icon": "phone", "inline": { "type": "phone", "value": "+15551234567" } },
{ "label": "Leave a Review", "icon": "star", "action_id": "e5f6a7b8-..." }
]
}
Worker behavior: Renders a branded button grid. Each tap navigates to /IDENTIFIER/b/<slot>, which logs a child scan with parent_scan_id and resolves the button's action_id or inline target.
HTML Action
Type: "html" · Tier: Starter
primary_data fields:
| Field | Type | Description |
|---|---|---|
content | string | Required. Raw HTML served to the scanner. Supports {{variable}} interpolation. No sanitization — content is trusted (owner-authored). |
{
"content": "<!DOCTYPE html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'><title>Welcome</title></head><body style='font-family:system-ui;padding:24px;'><h1>Welcome, {{customer_label}}!</h1><p>You scanned {{identifier}}.</p></body></html>"
}
Worker behavior: Returns the raw content with Content-Type: text/html; charset=UTF-8. No Tapped chrome, no CSS injection, no brand theme applied (HTML is intentionally NOT in BRANDABLE_TYPES). If content is empty, serves <p>No content.</p>.
Gotcha: If your HTML uses literal {{...}} patterns (e.g. inline JavaScript templating), they will get replaced or stripped. Avoid by escaping curly braces or structuring code to avoid the pattern.
Message Action
Type: "message" · Tier: Starter
primary_data fields:
| Field | Type | Description |
|---|---|---|
title | string | Required. Title displayed prominently. Supports {{variable}} interpolation. |
content | string | Required. Rich HTML body (in the dashboard, edited via Quill rich-text editor; the API accepts any HTML). Supports {{variable}} interpolation. |
{
"title": "Welcome, {{customer_label}}!",
"content": "<p>Thanks for scanning <strong>{{nickname}}</strong>.</p><p>Here's what you need to know:</p><ul><li>Item one</li><li>Item two</li></ul>"
}
Worker behavior: Renders a branded message page with the title and HTML content. Respects brand theme cascade. Suitable for short announcements ("Closed for the holidays"), welcome notes, or rich onboarding content.
A/B Split Action
Type: "ab_split" · Tier: Professional
primary_data fields:
| Field | Type | Description |
|---|---|---|
action_a_id | uuid | Required. UUID of an existing action (variant A). Must not be another ab_split action. |
action_b_id | uuid | Required. UUID of an existing action (variant B). Must not be another ab_split action. |
weight_a | integer | Optional. Percentage chance (0–100) that variant A is served. Default: 50. Variant B gets 100 - weight_a. |
{
"action_a_id": "a1b2c3d4-1234-5678-90ab-cdef12345678",
"action_b_id": "e5f6a7b8-1234-5678-90ab-cdef87654321",
"weight_a": 70
}
Worker behavior: At scan time, rolls a random number 0–99. If < weight_a, fetches and runs action_a_id; else action_b_id. The variant chosen is logged on the scan record as ab_variant: "A" or "B" for per-variant analytics. If either variant's action is missing, falls back to an error page.
Prerequisites: both variant actions must exist in the owner's library before the ab_split is created.
Variable Interpolation
Scan-context tokens available in action payloads
Several action types support {{token}} interpolation in their text fields. At scan time, the worker replaces each token with the corresponding value from the scan context. Unknown tokens are replaced with empty strings (not left as {{token}}).
Where interpolation is applied
| Action Type | Fields with interpolation |
|---|---|
url | None (use url_params with type: "variable" instead — see URL schema) |
sms | body |
email | subject, body |
html | content |
message | title, content |
Available tokens
| Token | Value at scan time |
|---|---|
{{identifier}} | Tag short code (e.g. A6CJB) |
{{nickname}} | Tag's friendly name (may be empty) |
{{group_name}} | Tag's group name (or empty string if ungrouped) |
{{customer_label}} | Customer Identity Name field |
{{customer_id}} | Customer Identity External ID field |
{{campaign_id}} | Tag's campaign UUID (or empty) |
{{scan_count}} | Total scans on this tag (integer) |
{{timestamp}} | Current scan timestamp (ISO 8601) |
{{country}} | 2-letter country code from Cloudflare geo (may be empty) |
{{city}} | City from Cloudflare geo (may be empty) |
{{region}} | Region / state from Cloudflare geo (may be empty) |
{{device_type}} | Parsed device type (e.g. Mobile, Tablet, Desktop) |
{{os}} | Operating system (e.g. iOS, Android, Windows) |
{{browser}} | Browser name |
{{action_type}} | Resolved action type |
{{action_title}} | Resolved action's title |
{{owner_email}} | Tag owner's email (rarely useful in scanner-facing content) |
Note on metadata tokens: Tag metadata is also exposed in the webhook payload as metadata.KEY, but is not available as {{metadata.KEY}} in action text fields — only the canonical tokens above. To include custom field values on scan pages, surface them via the form's pre-fill mechanism or use the HTML action with server-side templating in your destination URL.
Example: SMS body with interpolation
{
"type": "sms",
"primary_data": {
"to": "+15551234567",
"body": "Hi! I scanned tag {{identifier}} ({{nickname}}) in {{city}} on {{timestamp}}."
}
}
At scan time on tag A6CJB ("Reception Desk") scanned from Atlanta:
body=Hi! I scanned tag A6CJB (Reception Desk) in Atlanta on 2026-05-29T18:32:00.000Z.
Error Codes
Standard HTTP error responses and troubleshooting
| Status | Error Code | Description | Cause |
|---|---|---|---|
| 200 | — | Success | Request completed successfully |
| 400 | bad_request |
Invalid request | Malformed JSON, missing required fields, invalid parameters |
| 401 | unauthorized |
Missing or invalid API key | No Authorization header, invalid token, expired key |
| 403 | forbidden |
Insufficient permissions | API key doesn't have required scope (read/write/admin) |
| 404 | not_found |
Resource not found | Tag, action, or resource doesn't exist |
| 409 | conflict |
Conflict / Invalid state | Trying to delete an action assigned to tags |
| 429 | rate_limited |
Rate limit exceeded | Too many requests in a time window |
Error Response Format
{
"error": {
"status": 400,
"code": "bad_request",
"message": "Missing required field: nickname",
"details": {
"field": "nickname",
"reason": "required"
}
}
}
Common Errors & Solutions
401 Unauthorized
- Missing
Authorizationheader - Invalid API key format (must start with
ch_live_) - Key has been revoked or expired
- Typo in the key
403 Forbidden
- API key missing required scope (e.g., trying to write with
read-only key) - Trying to access another account's data
read and write scopes.
404 Not Found
- Tag ID, action ID, or other resource doesn't exist
- Typo in the UUID or identifier
- Resource was deleted
409 Conflict
- Trying to delete an action assigned to one or more tags
- Invalid state transition
Rate Limits
API request quotas and throttling
Tapped applies rate limits to prevent abuse and ensure fair service:
| Limit Type | Quota | Window |
|---|---|---|
| Requests per minute | 300 | 1 minute (rolling) |
| Requests per day | 100,000 | 24 hours (UTC) |
| Concurrent requests | 50 | — |
| Bulk operation size | 500 items | Per request |
Rate Limit Headers
Every API response includes rate limit information:
X-RateLimit-Limit: 300 X-RateLimit-Remaining: 287 X-RateLimit-Reset: 1681234567
Hitting Rate Limits
When you exceed the rate limit, you'll receive a 429 response:
{
"error": {
"status": 429,
"code": "rate_limited",
"message": "Rate limit exceeded. Try again in 45 seconds."
}
}
Best Practices to Avoid Rate Limits
- Use bulk endpoints instead of making multiple individual requests
- Implement exponential backoff when receiving 429 responses
- Cache responses when data doesn't change frequently
- Batch operations — process 500 items per request
- Monitor rate limit headers and slow down if approaching limits
- Distribute requests evenly across the time window
List Actions
Retrieve all actions in your library
Parameters:
| Parameter | Type | Description |
|---|---|---|
type |
string | Optional. Filter by type: url, vcard, form, ab_split, wifi, email, phone |
limit |
integer | Optional. Max results (default 100) |
{
"data": { ... }
}
Get Action
Retrieve a specific action
Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
uuid | Required. Action UUID |
{
"data": { ... }
}
Create Action
Create a new action in your library
Parameters:
| Parameter | Type | Description |
|---|---|---|
title |
string | Required. Action name |
type |
string | Required. url, vcard, form, ab_split, wifi, email, or phone |
primary_data |
string | Required. Data for the action type |
{"title":"Spring Sale Landing","type":"url","primary_data":"https://example.com/sale"}
{
"data": { ... }
}
Update Action
Modify an existing action
Parameters:
| Parameter | Type | Description |
|---|---|---|
title |
string | Optional. New title |
primary_data |
string | Optional. Updated data |
{"title":"Spring Sale Landing - Updated"}
{
"data": { ... }
}
Delete Action
Delete an action (409 if assigned to tags)
Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
uuid | Required. Action UUID |
{
"data": { ... }
}
List Sequences
Retrieve all action sequences
Parameters:
| Parameter | Type | Description |
|---|---|---|
limit |
integer | Optional. Max results |
{
"data": { ... }
}
Get Sequence
Retrieve a specific sequence
Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
uuid | Required. Sequence UUID |
{
"data": { ... }
}
Create Sequence
Create a sequence that rotates through actions on each scan
Parameters:
| Parameter | Type | Description |
|---|---|---|
name |
string | Required. Sequence name |
action_ids |
array | Required. Array of action UUIDs |
mode |
string | Optional. loop (default), linear, one_shot, or random |
{"name":"Daily Promo Rotation","action_ids":["uuid1","uuid2"],"mode":"loop"}
{
"data": { ... }
}
List Schedules
Retrieve all time-based schedules
Parameters:
| Parameter | Type | Description |
|---|---|---|
limit |
integer | Optional. Max results |
{
"data": { ... }
}
Get Schedule
Retrieve a specific schedule with all time windows
Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
uuid | Required. Schedule UUID |
{
"data": { ... }
}
Create Schedule
Create a schedule with time-based action routing
Parameters:
| Parameter | Type | Description |
|---|---|---|
name |
string | Required. Schedule name |
timezone |
string | Required. TZ identifier (e.g., America/Los_Angeles) |
windows |
array | Required. Array of time windows |
{"name":"Business Hours","timezone":"America/New_York","windows":[{"start_time":"09:00","end_time":"17:00","day_of_week":"1-5","action_id":"uuid"}]}
{
"data": { ... }
}
List Routes
Retrieve all conditional routes
Parameters:
| Parameter | Type | Description |
|---|---|---|
limit |
integer | Optional. Max results |
{
"data": { ... }
}
Get Route
Retrieve a specific conditional route
Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
uuid | Required. Route UUID |
{
"data": { ... }
}
Create Route
Create a conditional routing rule
Parameters:
| Parameter | Type | Description |
|---|---|---|
name |
string | Required. Route name |
fallback_action_id |
uuid | Optional. Default action if no condition matches |
{"name":"Device-Based Routing","fallback_action_id":"uuid"}
{
"data": { ... }
}
List Groups
Retrieve all tag groups
Parameters:
| Parameter | Type | Description |
|---|---|---|
limit |
integer | Optional. Max results |
{
"data": { ... }
}
Get Group
Retrieve a specific group with tag count
Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
uuid | Required. Group UUID |
{
"data": { ... }
}
Create Group
Create a new tag group
Parameters:
| Parameter | Type | Description |
|---|---|---|
name |
string | Required. Group name |
active_action_id |
uuid | Optional. Default action for group |
{"name":"Trade Show 2026"}
{
"data": { ... }
}
Assign Action to Group
Set default action for a group
Parameters:
| Parameter | Type | Description |
|---|---|---|
action_id |
uuid | Required. Action UUID |
{"action_id":"uuid"}
{
"data": { ... }
}
Update Group
Modify group name or assignment
Parameters:
| Parameter | Type | Description |
|---|---|---|
name |
string | Optional. New name |
{"name":"Trade Show 2026 - Updated"}
{
"data": { ... }
}
Delete Group
Delete a group (ungroups all tags)
Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
uuid | Required. Group UUID |
{
"data": { ... }
}
List Scans
Retrieve scan events across all tags
Parameters:
| Parameter | Type | Description |
|---|---|---|
porter_id |
uuid | Optional. Filter by tag |
from |
date | Optional. Start date |
to |
date | Optional. End date |
limit |
integer | Optional. Max results |
{
"data": { ... }
}
Analytics Summary
Get account-wide scan statistics
{
"data": { ... }
}
Tag Rankings
Get scan counts ranked by tag
Parameters:
| Parameter | Type | Description |
|---|---|---|
order |
string | Optional. asc or desc (default) |
limit |
integer | Optional. Max results |
{
"data": { ... }
}
List Webhook Configs
Retrieve all webhook profiles (Professional tier)
{
"data": { ... }
}
Create Webhook Config
Create a new webhook profile with field mapping
Parameters:
| Parameter | Type | Description |
|---|---|---|
name |
string | Required. Config name |
url |
string | Required. Webhook URL |
method |
string | Optional. POST (default), PUT, or PATCH |
headers |
object | Optional. Custom HTTP headers |
field_map |
object | Optional. Tapped variable -> custom field mapping |
static_fields |
object | Optional. Static key-value pairs |
include_tags |
boolean | Optional. Include tag data in payload |
{"name":"CRM Integration","url":"https://crm.example.com/webhook","method":"POST"}
{
"data": { ... }
}
Attach Webhook to Tags
Attach a webhook config to tags and/or groups
Parameters:
| Parameter | Type | Description |
|---|---|---|
porter_ids |
array | Optional. Tag UUIDs |
group_ids |
array | Optional. Group UUIDs |
{"porter_ids":["uuid1","uuid2"]}
{
"data": { ... }
}
Test Webhook Config
Send a test webhook payload
{
"data": { ... }
}
List API Keys
List all API keys (admin scope required)
{
"data": { ... }
}
Create API Key
Generate a new API key
Parameters:
| Parameter | Type | Description |
|---|---|---|
name |
string | Required. Key name |
scopes |
array | Required. read, write, and/or admin |
{"name":"Zapier","scopes":["read","write"]}
{
"data": { ... }
}
Revoke API Key
Revoke an API key (admin scope required)
Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Required. Key ID |
{
"data": { ... }
}
Health Check
Check API status (no authentication required)
{
"status": "ok",
"version": "v2",
"timestamp": "2026-05-27T18:30:00.000Z"
}
Update Sequence
Modify a sequence's name, steps, mode, or active state
Partial update. Tags already advancing through the sequence keep their current_index pointer — be careful when reducing action_ids below the existing index, as scanners past the new end will roll over according to the sequence's mode.
Required scope: write
Updatable Fields:
| Field | Type | Description |
|---|---|---|
name | string | Optional. Sequence display name. |
mode | string | Optional. One of: loop, linear, one_shot, random. |
action_ids | array | Optional. Ordered array of action UUIDs to serve, one per step. |
active | boolean | Optional. Pause/resume sequence advancement. |
tags | array | Optional. String tags for organization (not scan-tags). |
{
"name": "5-Day Welcome (Updated)",
"action_ids": ["a1...", "a2...", "a3...", "a4...", "a5..."],
"mode": "linear"
}
{
"data": { "id": "7c2f4d-...", "name": "5-Day Welcome (Updated)", "updated_at": "..." }
}
Delete Sequence
Permanently delete a sequence (409 if assigned to tags)
Returns 409 Conflict if any tag has this sequence assigned. Reassign or clear those tags before deleting.
Required scope: write
{
"data": { "id": "7c2f4d-...", "deleted": true }
}
{
"error": { "message": "Sequence is currently assigned to one or more porters. Reassign them first." }
}
Update Schedule
Modify a schedule's name, windows, timezone, fallback, or override
Updates a schedule via the save_schedule RPC, which performs a full upsert of the schedule + its windows. Fields you don't include keep their current values, except windows: passing an empty or missing windows array replaces the current windows with an empty set.
Required scope: write
Updatable Fields:
| Field | Type | Description |
|---|---|---|
name | string | Optional. Schedule display name. |
timezone | string | Optional. IANA timezone (e.g., America/New_York). |
action_id | uuid | Optional. Default action when no window matches. |
fallback_action_id | uuid | Optional. Secondary fallback action. |
override_action_id | uuid | Optional. Action to use when override_active is true. |
override_active | boolean | Optional. Toggle override mode on/off. |
day_of_week | array | Optional. Days schedule is active (auto-computed from windows if omitted). |
start_date | date | Optional. ISO date for schedule activation. |
end_date | date | Optional. ISO date for schedule expiration. |
windows | array | Optional. Array of { start_time, end_time, label, action_id, day_of_week } objects. |
{
"name": "Lunch/Dinner Schedule",
"timezone": "America/New_York",
"action_id": "e1f4...",
"windows": [
{ "start_time": "11:00", "end_time": "15:00", "label": "Lunch", "action_id": "lunch-act...", "day_of_week": [1,2,3,4,5] },
{ "start_time": "17:00", "end_time": "22:00", "label": "Dinner", "action_id": "dinner-act...", "day_of_week": [1,2,3,4,5,6,0] }
]
}
{
"data": { "(updated schedule with windows)" },
"status": "updated"
}
Delete Schedule
Permanently delete a schedule and all its windows
Cascades schedule_windows deletion. Returns 409 Conflict if any tag has this schedule assigned.
Required scope: write
{ "data": { "id": "...", "deleted": true } }
Update Route
Modify a conditional route's name or fallback action
The route's conditions are stored separately and edited via the dashboard. This endpoint covers the route-level fields. Route conditions API is on the roadmap.
Required scope: write · Tier: Professional
Updatable Fields:
| Field | Type | Description |
|---|---|---|
name | string | Optional. Route display name. |
fallback_action_id | uuid | Optional. Action served when no condition matches. |
{
"name": "Time-of-Day Router",
"fallback_action_id": "e1f4..."
}
{
"data": { "id": "...", "name": "Time-of-Day Router", "updated_at": "..." }
}
Delete Route
Permanently delete a conditional route (409 if assigned to tags)
Returns 409 Conflict if any tag has this route assigned. Reassign or clear those tags before deleting.
Required scope: write · Tier: Professional
{ "data": { "id": "...", "deleted": true } }
Webhooks Overview
Two webhook systems: reusable configs (Professional) and legacy subscriptions (all tiers)
Tapped exposes two parallel webhook systems, each suited to a different scale of use:
Webhook Configs (Professional tier — recommended)
Reusable webhook configurations with field mapping, static fields, custom HTTP method/headers, and attach-to-many-tags semantics. Designed for fleet-scale integrations where the same endpoint logic applies to dozens or hundreds of tags. A single config can be attached to any combination of individual tags and groups.
- List Configs — enumerate all webhook configs for the account
- Get Config — read a single config including its attached porter/group IDs
- Create Config — define a new reusable webhook
- Update Config — change url, headers, mapping, active state
- Attach / Detach — bind/unbind to tags and groups
- Test Config — fire a synthetic event to verify reachability
- Delete Config
Webhook Subscriptions (legacy — all tiers)
Single-URL webhook subscriptions, one per owner. Receive scan events for everything the account owns. Useful for simple "send every scan to my Slack" integrations. See Legacy Subscriptions.
Validation & Security
Outgoing payloads include a timestamp and (when a secret is set on the subscription/config) an HMAC SHA-256 signature in the X-Tapped-Signature header. See Best Practices → Webhook Validation for the verification code template.
Get Webhook Config
Retrieve a single webhook config with its full attached tag and group lists
Returns the config record plus porter_ids (tags this config is attached to) and group_ids (groups this config is attached to).
Required scope: read · Tier: Professional
{
"data": {
"id": "abc123...",
"name": "Slack #tag-scans",
"url": "https://hooks.slack.com/services/...",
"method": "POST",
"headers": { "X-Custom": "value" },
"field_map": { "text": "identifier" },
"static_fields": { "channel": "#tag-scans" },
"include_tags": false,
"active": true,
"porter_ids": ["a7c9...", "b3d4..."],
"group_ids": ["f3a7..."]
}
}
Update Webhook Config
Modify any field of an existing webhook config
Partial update — only fields you include are changed. To pause delivery without losing the config, set active: false.
Required scope: write · Tier: Professional
Updatable Fields:
| Field | Type | Description |
|---|---|---|
name | string | Optional. Display name for the config. |
url | string | Optional. Target endpoint URL. |
method | string | Optional. HTTP method (default POST). |
headers | object | Optional. Custom HTTP headers as a key/value object. |
field_map | object | Optional. Mapping from outgoing payload keys to scan event fields. |
static_fields | object | Optional. Static values to include in every payload. |
include_tags | boolean | Optional. Include the tag's full metadata + nickname in payloads. |
active | boolean | Optional. Toggle delivery on/off. |
{
"name": "Slack #tag-scans (paused)",
"active": false
}
Detach Webhook from Tags
Unbind a webhook config from specific tags and/or groups
Removes the attachment between the config and the specified tags or groups. The config itself remains and can be re-attached later. To delete the config entirely, use Delete Config.
Required scope: write · Tier: Professional
Parameters:
| Parameter | Type | Description |
|---|---|---|
porter_ids | array | Optional. Tag UUIDs to detach. |
group_ids | array | Optional. Group UUIDs to detach. |
{
"porter_ids": ["a7c9...", "b3d4..."],
"group_ids": ["f3a7..."]
}
{
"data": {
"webhook_config_id": "abc123...",
"detached_porters": 2,
"detached_groups": 1
}
}
Delete Webhook Config
Permanently delete a webhook config and all its attachments
Removes the config and cascades all porter_webhooks + group_webhooks junction rows. Tags previously attached to this config simply stop receiving its events; they aren't otherwise modified.
Required scope: write · Tier: Professional
{ "data": { "id": "abc123...", "deleted": true } }
Legacy Webhook Subscriptions
Simple one-URL-per-event-type webhooks (all tiers)
The legacy /v1/webhooks endpoints provide a simpler webhook model than Webhook Configs: each subscription has a single URL, an array of event types, an optional HMAC secret, and an active flag. Available to all tiers.
For fleet-scale use with per-tag attachment and field mapping, use Webhook Configs instead.
List Subscriptions
Required scope: read
Create Subscription
Required scope: write
Body:
| Field | Type | Description |
|---|---|---|
url | string | Required. Valid HTTPS URL to deliver events to. |
label | string | Optional. Display name. |
events | array | Optional. Event types to subscribe to. Default: ["scan.created"]. |
secret | string | Optional. HMAC secret for payload signing. |
{
"url": "https://example.com/tapped-webhook",
"label": "Production scan hook",
"events": ["scan.created"],
"secret": "whsec_..."
}
Update Subscription
Updatable Fields: label, url, events, secret, is_active
Delete Subscription
Returns 204 No Content on success.
Test Subscription
Fires a synthetic event with event: "test" and an X-Tapped-Event: test header. Returns delivery status, HTTP response code, and any error.
{
"data": {
"delivered": true,
"status": 200,
"url": "https://example.com/tapped-webhook"
}
}