Slack Notification Example
This guide shows you how to receive real-time Slack notifications when your tagd-ai tags are scanned. We'll build a webhook receiver that forwards tag scan events to a Slack channel.
What You'll Build
Tag Scanned → tagd-ai webhook → Your Cloudflare Worker → Slack Channel
When someone scans one of your tags, you'll receive a Slack message like:
Tag Scanned: Product Manual
Someone scanned your tag
a7Bx9k
- Location: San Francisco, US
- Device: Mobile (iOS)
- Time: Jan 15, 2024 2:30 PM
Prerequisites
- A Slack workspace where you can create apps
- A Cloudflare account (free tier works)
- A tagd-ai account with Pro plan or higher
Step 1: Create a Slack Incoming Webhook
1.1 Create a Slack App
- Go to api.slack.com/apps
- Click Create New App → From scratch
- Name it "tagd-ai Notifications" and select your workspace
- Click Create App
1.2 Enable Incoming Webhooks
- In your app settings, go to Incoming Webhooks
- Toggle Activate Incoming Webhooks to On
- Click Add New Webhook to Workspace
- Select the channel for notifications (e.g.,
#tag-alerts) - Click Allow
1.3 Copy Your Webhook URL
You'll get a URL like:
https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
Save this URL—you'll need it in the next step.
Step 2: Deploy Your Webhook Receiver
Choose your preferred platform:
- Cloudflare Workers (JavaScript) — Recommended
- Python (Flask)
- Test with curl
Cloudflare Workers (JavaScript)
2.1 Install Wrangler CLI
npm install -g wrangler
wrangler login
2.2 Create Your Worker
mkdir tagd-slack-webhook
cd tagd-slack-webhook
wrangler init
2.3 Add the Code
Replace the contents of src/index.js (or src/index.ts for TypeScript):
/**
* tagd-ai Webhook → Slack Notification
*
* Receives tag scan events from tagd-ai and posts to Slack
*/
// Configuration - set these in Cloudflare dashboard or wrangler.toml
// SLACK_WEBHOOK_URL: Your Slack incoming webhook URL
// TAGD_WEBHOOK_SECRET: Your tagd-ai webhook secret (optional, for verification)
export default {
async fetch(request, env, ctx) {
// Only accept POST requests
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
try {
// Parse the incoming webhook payload
const payload = await request.json();
// Verify the webhook signature (recommended for production)
const isValid = await verifySignature(request, payload, env.TAGD_WEBHOOK_SECRET);
if (env.TAGD_WEBHOOK_SECRET && !isValid) {
console.error('Invalid webhook signature');
return new Response('Invalid signature', { status: 401 });
}
// Only process tag_scan events (ignore others)
if (payload.event !== 'tag_scan' && payload.event !== 'tag_read') {
return new Response('Event ignored', { status: 200 });
}
// Build and send Slack message
const slackMessage = buildSlackMessage(payload);
await sendToSlack(env.SLACK_WEBHOOK_URL, slackMessage);
return new Response('OK', { status: 200 });
} catch (error) {
console.error('Webhook processing error:', error);
return new Response('Internal error', { status: 500 });
}
}
};
/**
* Verify tagd-ai webhook signature
*/
async function verifySignature(request, payload, secret) {
if (!secret) return true; // Skip if no secret configured
const signature = request.headers.get('X-TagdAI-Signature');
if (!signature) return false;
// Parse signature header: t=timestamp,v1=signature
const parts = {};
signature.split(',').forEach(part => {
const [key, value] = part.split('=');
parts[key] = value;
});
const timestamp = parts['t'];
const expectedSig = parts['v1'];
// Check timestamp is within 5 minutes (prevent replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false;
}
// Compute expected signature
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload));
const computedSig = Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return computedSig === expectedSig;
}
/**
* Build Slack Block Kit message
*/
function buildSlackMessage(payload) {
const { data } = payload;
const timestamp = new Date(payload.timestamp).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short'
});
// Build location string
let location = 'Unknown location';
if (data.city && data.country) {
location = `${data.city}, ${data.country}`;
} else if (data.country) {
location = data.country;
}
// Build device string
let device = 'Unknown device';
if (data.device && data.os) {
device = `${data.device} (${data.os})`;
} else if (data.device) {
device = data.device;
}
// Slack Block Kit message for rich formatting
return {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: `Tag Scanned: ${data.title || 'Untitled Tag'}`,
emoji: true
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Someone scanned your tag \`${data.shortId}\``
}
},
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Location:*\n${location}`
},
{
type: 'mrkdwn',
text: `*Device:*\n${device}`
},
{
type: 'mrkdwn',
text: `*Time:*\n${timestamp}`
},
{
type: 'mrkdwn',
text: `*Tag ID:*\n${data.shortId}`
}
]
},
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'View Tag',
emoji: true
},
url: `https://tagd-ai.com/${data.shortId}`,
action_id: 'view_tag'
}
]
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `Delivery ID: ${payload.deliveryId}`
}
]
}
]
};
}
/**
* Send message to Slack
*/
async function sendToSlack(webhookUrl, message) {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(message)
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Slack API error: ${response.status} - ${text}`);
}
}
2.4 Configure Secrets
Add your Slack webhook URL as a secret:
wrangler secret put SLACK_WEBHOOK_URL
# Paste your Slack webhook URL when prompted
# Optional: Add your tagd-ai webhook secret for signature verification
wrangler secret put TAGD_WEBHOOK_SECRET
2.5 Deploy
wrangler deploy
You'll get a URL like: https://tagd-slack-webhook.<your-subdomain>.workers.dev
Python (Flask)
If you prefer Python, here's an equivalent implementation for any Python hosting platform (AWS Lambda, Google Cloud Functions, your own server, etc.):
Flask Example
"""
tagd-ai Webhook → Slack Notification
Receives tag scan events from tagd-ai and posts to Slack.
Deploy to: AWS Lambda, Google Cloud Functions, Heroku, or any Python host.
"""
import os
import json
import hmac
import hashlib
import time
from datetime import datetime
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
# Configuration from environment variables
SLACK_WEBHOOK_URL = os.environ.get('SLACK_WEBHOOK_URL')
TAGD_WEBHOOK_SECRET = os.environ.get('TAGD_WEBHOOK_SECRET')
def verify_signature(request_headers: dict, payload: dict, secret: str) -> bool:
"""Verify tagd-ai webhook signature."""
if not secret:
return True # Skip verification if no secret configured
signature_header = request_headers.get('X-TagdAI-Signature', '')
if not signature_header:
return False
# Parse signature: t=timestamp,v1=signature
parts = dict(part.split('=', 1) for part in signature_header.split(',') if '=' in part)
timestamp = parts.get('t', '')
expected_sig = parts.get('v1', '')
# Check timestamp is within 5 minutes
try:
ts = int(timestamp)
if abs(time.time() - ts) > 300:
return False
except ValueError:
return False
# Compute signature
signed_payload = f"{timestamp}.{json.dumps(payload, separators=(',', ':'))}"
computed_sig = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed_sig, expected_sig)
def build_slack_message(payload: dict) -> dict:
"""Build Slack Block Kit message."""
data = payload.get('data', {})
# Format timestamp
try:
ts = datetime.fromisoformat(payload['timestamp'].replace('Z', '+00:00'))
timestamp = ts.strftime('%b %d, %Y %I:%M %p')
except:
timestamp = payload.get('timestamp', 'Unknown')
# Build location string
city = data.get('city', '')
country = data.get('country', '')
if city and country:
location = f"{city}, {country}"
elif country:
location = country
else:
location = 'Unknown location'
# Build device string
device_type = data.get('device', '')
os_name = data.get('os', '')
if device_type and os_name:
device = f"{device_type} ({os_name})"
elif device_type:
device = device_type
else:
device = 'Unknown device'
title = data.get('title', 'Untitled Tag')
short_id = data.get('shortId', 'unknown')
return {
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"Tag Scanned: {title}",
"emoji": True
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"Someone scanned your tag `{short_id}`"
}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*Location:*\n{location}"},
{"type": "mrkdwn", "text": f"*Device:*\n{device}"},
{"type": "mrkdwn", "text": f"*Time:*\n{timestamp}"},
{"type": "mrkdwn", "text": f"*Tag ID:*\n{short_id}"}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "View Tag", "emoji": True},
"url": f"https://tagd-ai.com/{short_id}",
"action_id": "view_tag"
}
]
},
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": f"Delivery ID: {payload.get('deliveryId', 'unknown')}"}
]
}
]
}
def send_to_slack(message: dict) -> bool:
"""Send message to Slack."""
if not SLACK_WEBHOOK_URL:
raise ValueError("SLACK_WEBHOOK_URL not configured")
response = requests.post(
SLACK_WEBHOOK_URL,
json=message,
headers={'Content-Type': 'application/json'}
)
response.raise_for_status()
return True
@app.route('/webhook', methods=['POST'])
def webhook():
"""Handle incoming tagd-ai webhooks."""
try:
payload = request.get_json()
# Verify signature
if TAGD_WEBHOOK_SECRET:
if not verify_signature(dict(request.headers), payload, TAGD_WEBHOOK_SECRET):
return jsonify({'error': 'Invalid signature'}), 401
# Only process tag scan events
event_type = payload.get('event', '')
if event_type not in ('tag_scan', 'tag_read'):
return jsonify({'status': 'ignored'}), 200
# Build and send Slack message
slack_message = build_slack_message(payload)
send_to_slack(slack_message)
return jsonify({'status': 'ok'}), 200
except Exception as e:
print(f"Error processing webhook: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint."""
return jsonify({'status': 'healthy'}), 200
if __name__ == '__main__':
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))
Requirements
Create requirements.txt:
flask>=2.0.0
requests>=2.28.0
gunicorn>=21.0.0
Deploy to Google Cloud Run
# Build and deploy
gcloud run deploy tagd-slack-webhook \
--source . \
--platform managed \
--region us-central1 \
--allow-unauthenticated \
--set-env-vars "SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
Deploy to Heroku
# Create Procfile
echo "web: gunicorn app:app" > Procfile
# Deploy
heroku create tagd-slack-webhook
heroku config:set SLACK_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
git push heroku main
Step 3: Register Your Webhook with tagd-ai
Now connect your receiver to tagd-ai:
curl -X POST https://dktmitcptketnziahoho.supabase.co/functions/v1/api-v1/webhooks \
-H "Authorization: Bearer YOUR_TAGD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://tagd-slack-webhook.YOUR-SUBDOMAIN.workers.dev",
"events": ["tag_scan", "tag_read"],
"description": "Slack notifications for tag scans"
}'
You'll receive a response with your webhook ID and secret:
{
"id": "wh_abc123",
"url": "https://tagd-slack-webhook.your-subdomain.workers.dev",
"events": ["tag_scan", "tag_read"],
"secret": "whsec_xxxxxxxxxxxxxxxx",
"enabled": true,
"createdAt": "2024-01-15T14:30:00Z"
}
Save the secret — use it for signature verification.
Testing with curl
Test Your Receiver Locally
Before connecting to tagd-ai, test your webhook receiver with a simulated payload:
curl -X POST https://tagd-slack-webhook.YOUR-SUBDOMAIN.workers.dev \
-H "Content-Type: application/json" \
-d '{
"event": "tag_scan",
"timestamp": "2024-01-15T14:30:00Z",
"webhookId": "wh_test123",
"deliveryId": "del_test456",
"data": {
"tagId": "550e8400-e29b-41d4-a716-446655440000",
"shortId": "a7Bx9k",
"title": "Product Manual",
"device": "Mobile",
"os": "iOS",
"country": "US",
"city": "San Francisco",
"coordinates": {
"lat": 37.7749,
"lng": -122.4194
}
}
}'
Test via tagd-ai
Use the webhook test endpoint:
curl -X POST https://dktmitcptketnziahoho.supabase.co/functions/v1/api-v1/webhooks/YOUR_WEBHOOK_ID/test \
-H "Authorization: Bearer YOUR_TAGD_API_KEY"
Complete Payload Reference
Here's the full payload you'll receive for tag_scan events:
{
"event": "tag_scan",
"timestamp": "2024-01-15T14:30:00Z",
"webhookId": "wh_abc123def456",
"deliveryId": "del_xyz789",
"data": {
"tagId": "550e8400-e29b-41d4-a716-446655440000",
"shortId": "a7Bx9k",
"title": "Product Manual",
"ownerId": "user_123",
"ownerEmail": "[email protected]",
"readPermission": "public",
"writePermission": "owner",
"device": "Mobile",
"os": "iOS",
"browser": "Safari",
"country": "US",
"city": "San Francisco",
"region": "California",
"coordinates": {
"lat": 37.7749,
"lng": -122.4194,
"accuracy": 100
},
"referrer": "https://example.com",
"userAgent": "Mozilla/5.0..."
}
}
HTTP Headers
Every webhook includes these headers:
| Header | Description |
|---|---|
X-TagdAI-Signature | HMAC signature: t=timestamp,v1=signature |
X-TagdAI-Event | Event type (e.g., tag_scan) |
X-TagdAI-Delivery-ID | Unique delivery ID for deduplication |
X-TagdAI-Timestamp | Unix timestamp of the event |
Content-Type | application/json |
User-Agent | TagdAI-Webhook/1.0 |
Customization Ideas
Add Coordinates to the Message
// In buildSlackMessage(), add a map link:
if (data.coordinates?.lat && data.coordinates?.lng) {
const mapUrl = `https://maps.google.com/?q=${data.coordinates.lat},${data.coordinates.lng}`;
// Add to your Slack blocks
}
Filter by Tag
Only notify for specific tags:
const IMPORTANT_TAGS = ['a7Bx9k', 'b8Cy0l', 'c9Dz1m'];
if (!IMPORTANT_TAGS.includes(data.shortId)) {
return new Response('Tag not monitored', { status: 200 });
}
Different Channels per Tag
const TAG_CHANNELS = {
'a7Bx9k': 'https://hooks.slack.com/services/XXX/product-alerts',
'b8Cy0l': 'https://hooks.slack.com/services/XXX/inventory-alerts'
};
const webhookUrl = TAG_CHANNELS[data.shortId] || env.SLACK_WEBHOOK_URL;
Troubleshooting
Not Receiving Messages?
-
Check Cloudflare Worker logs:
wrangler tail -
Verify Slack webhook URL:
curl -X POST YOUR_SLACK_WEBHOOK_URL \
-H "Content-Type: application/json" \
-d '{"text": "Test message"}' -
Check tagd-ai webhook status:
curl https://dktmitcptketnziahoho.supabase.co/functions/v1/api-v1/webhooks/YOUR_WEBHOOK_ID \
-H "Authorization: Bearer YOUR_TAGD_API_KEY"
Signature Verification Failing?
- Ensure you're using the correct webhook secret from tagd-ai
- Check that your server's clock is synchronized (within 5 minutes)
- Verify you're computing the signature correctly with the raw JSON
Rate Limited?
tagd-ai sends max 1000 webhook deliveries per minute. If you're exceeding this:
- Consider batching notifications
- Filter events server-side before sending to Slack
Next Steps
- View all webhook events — Subscribe to more event types
- Webhook security — Best practices for production
- Webhook API reference — Full API documentation