@duvandroid/react-blind-agents
v0.4.1
Published
React component for the Blind Agents pixel widget — bug reporter, webchat, and product guides.
Maintainers
Readme
@duvandroid/react-blind-agents
React component for the Blind Agents pixel widget — AI bug reporter, webchat, and product guides.
Table of Contents
- Installation
- Quick Start
- React (Vite / CRA)
- Next.js — App Router
- Next.js — Pages Router
- Authenticated users
- All three widgets together
- Props reference
- HTML / Any website (script tag)
- Shopify
- Lovable
- Wix
- WordPress
- Webflow
- Squarespace
- Ghost
- Bubble
- Framer
- Google Tag Manager
- Webhooks — Slack integration
- Webhooks — n8n integration
- Webhook payload reference
- Signature verification
Installation
npm install @duvandroid/react-blind-agentsQuick Start
Paste this before </body> in any HTML file — no npm needed:
<script
src="https://cdn.blindagents.com/report.js"
data-api-key="YOUR_API_KEY"
data-primary-color="#e11d48"
data-title="Help Center"
data-report-btn-text="Report an issue"
data-btn-emoji="🔴"
data-btn-tooltip="Report an issue"
data-empty-text="No issues reported yet."
data-user-whatsapp="">
</script>Get your API key from Blind Agents → Settings → API Keys.
React (Vite / CRA)
// src/App.tsx
import { BlindAgents } from '@duvandroid/react-blind-agents';
export default function App() {
return (
<BlindAgents apiKey="YOUR_API_KEY">
<MyRoutes />
<BlindAgents.Report
primaryColor="#e11d48"
title="Help Center"
reportBtnText="Report an issue"
btnEmoji="🔴"
btnTooltip="Report an issue"
emptyText="No issues reported yet."
/>
</BlindAgents>
);
}Place <BlindAgents> once at the App root — the widget components render nothing in the DOM, only inject their scripts.
Next.js — App Router
Import from the /next subpath — it uses next/script internally for correct hydration and strategy support.
// app/layout.tsx
import { BlindAgents } from '@duvandroid/react-blind-agents/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<BlindAgents apiKey="YOUR_API_KEY">
<BlindAgents.Report
primaryColor="#e11d48"
title="Help Center"
reportBtnText="Report an issue"
btnEmoji="🔴"
strategy="afterInteractive"
/>
</BlindAgents>
</body>
</html>
);
}Next.js — Pages Router
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { BlindAgents } from '@duvandroid/react-blind-agents/next';
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Component {...pageProps} />
<BlindAgents apiKey="YOUR_API_KEY">
<BlindAgents.Report primaryColor="#e11d48" />
</BlindAgents>
</>
);
}Authenticated users
Use userWhatsapp to skip the in-widget identity verification step, and externalId to link the session to your own database record.
// components/Widgets.tsx
'use client';
import { BlindAgents } from '@duvandroid/react-blind-agents/next';
import { useAuth } from './AuthProvider';
export function Widgets() {
const { user } = useAuth();
return (
<BlindAgents
apiKey="YOUR_API_KEY"
/**
* userWhatsapp accepts a phone number OR email address.
* Passing it skips the verification prompt — the user is already known.
*/
userWhatsapp={user?.phone ?? user?.email}
/**
* externalId is your app's internal user ID (DB primary key, UUID, etc.).
* Stored as contact.external_id in Blind Agents so you can look up
* tickets and conversations by your own ID via the REST API.
* Works in Report, Chat, and Guide widgets.
*/
externalId={user?.id}
>
<BlindAgents.Report primaryColor="#e11d48" />
<BlindAgents.Chat agentId="YOUR_AGENT_ID" />
</BlindAgents>
);
}Place <Widgets /> inside your AuthProvider tree so useAuth() has access to the context.
All three widgets together
import { BlindAgents } from '@duvandroid/react-blind-agents';
export default function App() {
return (
<BlindAgents
apiKey="YOUR_API_KEY"
userWhatsapp="[email protected]"
externalId="usr_123"
>
<MyRoutes />
{/* Bug reporter — bottom-right (default) */}
<BlindAgents.Report
primaryColor="#e11d48"
title="Help Center"
reportBtnText="Report an issue"
btnEmoji="🔴"
/>
{/* Webchat — bottom-left */}
<BlindAgents.Chat
agentId="YOUR_AGENT_ID"
primaryColor="#625df5"
position="bottom-left"
fontFamily="Rounded"
/>
{/* Product guides — config is fully dashboard-driven */}
<BlindAgents.Guide />
</BlindAgents>
);
}Props reference
<BlindAgents> (root provider)
All props are inherited by child widgets unless overridden at the widget level.
| Prop | Type | Default | Description |
|---|---|---|---|
| apiKey | string | required | Your Blind Agents public API key (ba_...) |
| userWhatsapp | string | — | Pre-fill the user's phone number or email. When set, the in-widget verification prompt is skipped entirely. |
| externalId | string | — | Your app's internal user ID. Stored as contact.external_id so you can look up contacts by your own ID via the REST API. Does not skip the verification prompt on its own — combine with userWhatsapp for that. Supported by Report, Chat, and Guide. |
| apiUrl | string | https://api.blindagents.com | Override the API endpoint (self-hosting / proxy) |
| cdnBase | string | https://cdn.blindagents.com | Override the CDN base URL (self-hosting) |
| strategy | "afterInteractive" \| "lazyOnload" \| "beforeInteractive" | "afterInteractive" | Script loading strategy |
<BlindAgents.Report>
| Prop | Type | Default | Description |
|---|---|---|---|
| primaryColor | string | — | Accent color (any valid CSS color) |
| title | string | "Help Center" | Widget panel header title |
| reportBtnText | string | "Report an issue" | Report button label |
| btnEmoji | string | — | Emoji on the floating launcher button |
| iconUrl | string | — | Image URL for the launcher button (replaces emoji) |
| btnTooltip | string | — | Tooltip on the launcher button |
| emptyText | string | "No issues reported yet." | Text shown when there are no reports |
| position | WidgetPosition | "bottom-right" | Floating button position (preset or {bottom, right, …}) |
| anchor | string | — | CSS selector — mount inside an element instead of <body> |
| bubbleSize | number | 56 | Launcher button diameter in px |
| panelWidth | string | — | Panel width (any CSS length, e.g. "380px") |
| panelHeight | string | — | Panel height (any CSS length, e.g. "600px") |
| userWhatsapp | string | — | Per-widget override (inherits from <BlindAgents>) |
| externalId | string | — | Per-widget override |
| apiUrl | string | — | Per-widget override |
| cdnBase | string | — | Per-widget override |
| strategy | see above | — | Per-widget override |
| onLoad | () => void | — | Called when the script loads |
| onError | (error: Error) => void | — | Called if the script fails to load |
<BlindAgents.Chat>
| Prop | Type | Default | Description |
|---|---|---|---|
| agentId | string | — | The agent UUID from your Blind Agents dashboard |
| primaryColor | string | — | Accent color (any valid CSS color) |
| btnEmoji | string | — | Emoji on the floating launcher button |
| iconUrl | string | — | Image URL for the launcher button (replaces emoji) |
| btnTooltip | string | — | Tooltip on the launcher button |
| fontSize | string | — | Font size for chat text, e.g. "14px" |
| fontFamily | string | — | Font preset: "System" · "Serif" · "Mono" · "Rounded", or a custom stack |
| notificationSound | boolean | true | Enable or disable notification sound on incoming messages |
| position | WidgetPosition | "bottom-right" | Floating button position |
| anchor | string | — | CSS selector — mount inside an element instead of <body> |
| bubbleSize | number | 56 | Launcher button diameter in px |
| panelWidth | string | — | Panel width (any CSS length) |
| panelHeight | string | — | Panel height (any CSS length) |
| userWhatsapp | string | — | Per-widget override |
| externalId | string | — | Per-widget override |
| apiUrl | string | — | Per-widget override |
| cdnBase | string | — | Per-widget override |
| strategy | see above | — | Per-widget override |
| onLoad | () => void | — | Called when the script loads |
| onError | (error: Error) => void | — | Called if the script fails to load |
<BlindAgents.Guide>
The Guide widget reads its module configuration from the Blind Agents dashboard. No extra props are required, but you can still pass user identity, layout, and loading props.
| Prop | Type | Default | Description |
|---|---|---|---|
| position | WidgetPosition | "bottom-right" | Guide launcher position |
| anchor | string | — | CSS selector — mount inside an element |
| bubbleSize | number | 56 | Launcher button diameter in px |
| panelWidth | string | — | Panel width |
| panelHeight | string | — | Panel height |
| userWhatsapp | string | — | Per-widget override |
| externalId | string | — | Per-widget override |
| apiUrl | string | — | Per-widget override |
| cdnBase | string | — | Per-widget override |
| strategy | see above | — | Per-widget override |
| onLoad | () => void | — | Called when the script loads |
| onError | (error: Error) => void | — | Called if the script fails to load |
WidgetPosition type
type WidgetPosition =
| 'bottom-right' // default
| 'bottom-left'
| 'top-right'
| 'top-left'
| Record<string, string>; // custom CSS e.g. { bottom: "20px", right: "80px" }Why two import paths?
| Import | Use case |
|---|---|
| @duvandroid/react-blind-agents | Plain React — Vite, CRA, Remix, any non-Next.js |
| @duvandroid/react-blind-agents/next | Next.js — App Router & Pages Router |
Importing /next in a non-Next.js project will throw because next/script won't be available.
HTML / Any website (script tag)
No npm required. Paste before </body>:
<script
src="https://cdn.blindagents.com/report.js"
data-api-key="YOUR_API_KEY"
data-primary-color="#e11d48"
data-title="Help Center"
data-report-btn-text="Report an issue"
data-btn-emoji="🔴"
data-btn-tooltip="Report an issue"
data-empty-text="No issues reported yet."
data-user-whatsapp=""
data-user-email=""
data-user-full-name="">
</script>The data-* attribute names map 1:1 to the React props (kebab-case → camelCase).
Shopify
- Online Store → Themes → Edit code → Layout → theme.liquid
- Paste before
</body>:
{%- comment -%} Blind Agents widget {%- endcomment -%}
<script
src="https://cdn.blindagents.com/report.js"
data-api-key="YOUR_API_KEY"
data-primary-color="#e11d48"
data-title="Help Center"
data-report-btn-text="Report an issue"
data-btn-emoji="🔴"
data-btn-tooltip="Report an issue"
data-user-whatsapp="{{ customer.phone | default: '' }}"
data-user-email="{{ customer.email | default: '' }}"
data-user-full-name="{{ customer.name | default: '' }}"
data-external-id="{{ customer.id | default: '' }}">
</script>The Liquid variables auto-fill logged-in customer data — skipping identity verification for authenticated shoppers.
For Shopify Plus headless stores (Hydrogen / Remix), use the React npm package instead.
Lovable
Option A — Prompt Lovable:
"Install @duvandroid/react-blind-agents and add a BlindAgents provider with Report and Chat widgets to App.tsx with apiKey='YOUR_API_KEY' and primaryColor='#e11d48'"
Option B — Manual (index.html):
<!-- Paste in index.html before </body> -->
<script
src="https://cdn.blindagents.com/report.js"
data-api-key="YOUR_API_KEY"
data-primary-color="#e11d48"
data-title="Help Center"
data-report-btn-text="Report an issue"
data-btn-emoji="🔴">
</script>Wix
Option A — Custom Code (no-code, recommended):
- Settings → Custom Code → + Add Custom Code
- Paste the script tag, set placement to Body – end, apply to All Pages, load Once
Option B — Velo:
$w.onReady(() => {
const script = document.createElement('script');
script.src = 'https://cdn.blindagents.com/report.js';
script.setAttribute('data-api-key', 'YOUR_API_KEY');
script.setAttribute('data-primary-color', '#e11d48');
script.setAttribute('data-title', 'Help Center');
script.defer = true;
document.body.appendChild(script);
});WordPress
Via functions.php (child theme):
function blindagents_widget() {
echo '<script
src="https://cdn.blindagents.com/report.js"
data-api-key="YOUR_API_KEY"
data-primary-color="#e11d48"
data-title="Help Center"
data-report-btn-text="Report an issue"
data-btn-emoji="🔴"
defer>
</script>';
}
add_action('wp_footer', 'blindagents_widget');Via plugin (no-code): Install WPCode or Insert Headers and Footers, paste the script tag in the Footer section.
Via Elementor: Custom Code → Body End.
Webflow
Site Settings → Custom Code → Footer Code:
<script
src="https://cdn.blindagents.com/report.js"
data-api-key="YOUR_API_KEY"
data-primary-color="#e11d48"
data-title="Help Center"
data-report-btn-text="Report an issue"
data-btn-emoji="🔴"
defer>
</script>Squarespace
Requires Business plan or above.
Settings → Advanced → Code Injection → Footer:
<script
src="https://cdn.blindagents.com/report.js"
data-api-key="YOUR_API_KEY"
data-primary-color="#e11d48"
data-title="Help Center"
data-report-btn-text="Report an issue"
data-btn-emoji="🔴"
defer>
</script>Ghost
Admin → Settings → Code Injection → Site Footer:
<script
src="https://cdn.blindagents.com/report.js"
data-api-key="YOUR_API_KEY"
data-primary-color="#e11d48"
data-title="Help Center"
data-report-btn-text="Report an issue"
data-btn-emoji="🔴"
defer>
</script>Bubble
Settings → SEO / metatags → Script/meta tags in header:
<script
src="https://cdn.blindagents.com/report.js"
data-api-key="YOUR_API_KEY"
data-primary-color="#e11d48"
data-title="Help Center"
data-report-btn-text="Report an issue"
data-btn-emoji="🔴"
defer>
</script>Or add an HTML element on any page and paste the script tag there.
Framer
Site Settings → General → Custom Code → End of body tag:
<script
src="https://cdn.blindagents.com/report.js"
data-api-key="YOUR_API_KEY"
data-primary-color="#e11d48"
data-title="Help Center"
data-report-btn-text="Report an issue"
data-btn-emoji="🔴"
defer>
</script>Requires a Framer paid plan.
Google Tag Manager
- Tags → New → Custom HTML
- Paste:
<script>
(function() {
var el = document.createElement('script');
el.src = 'https://cdn.blindagents.com/report.js';
el.setAttribute('data-api-key', 'YOUR_API_KEY');
el.setAttribute('data-primary-color', '#e11d48');
el.setAttribute('data-title', 'Help Center');
el.setAttribute('data-report-btn-text', 'Report an issue');
el.setAttribute('data-btn-emoji', '🔴');
el.defer = true;
document.head.appendChild(el);
})();
</script>- Trigger: All Pages — Page View
- Submit and publish
Webhooks — Slack integration
Get real-time Slack notifications for every Blind Agents event.
1. Create a Slack Incoming Webhook
- Go to api.slack.com/apps → Create New App → From scratch
- Features → Incoming Webhooks → enable → Add New Webhook to Workspace
- Select the channel (e.g.
#bugs) and copy the webhook URL
Test it:
curl -X POST https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK \
-H "Content-Type: application/json" \
-d '{"text": "Blind Agents test message ✅"}'2. Create a Blind Agents webhook
In Blind Agents → Webhooks → Add webhook, set the URL to your server endpoint and select events.
3. Forward events to Slack (Node.js / Express)
import express from 'express';
import crypto from 'crypto';
const app = express();
app.use(express.raw({ type: 'application/json' })); // raw body required for signature check
const BA_SECRET = process.env.BLIND_AGENTS_WEBHOOK_SECRET;
const SLACK_URL = process.env.SLACK_WEBHOOK_URL;
function verify(rawBody: string, signature: string) {
const parts = Object.fromEntries(signature.split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', BA_SECRET!)
.update(`${parts.t}.${rawBody}`).digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
const recent = Date.now() / 1000 - Number(parts.t) < 300;
return valid && recent;
}
app.post('/webhooks/blind-agents', async (req, res) => {
const sig = req.headers['x-blindagents-signature'] as string;
if (!verify(req.body.toString(), sig)) return res.sendStatus(401);
const { event, data } = JSON.parse(req.body.toString());
const messages: Record<string, string> = {
'ticket.created': `🎫 *New ticket:* <${data.page_url}|${data.title}> — ${data.priority} priority`,
'ticket.status_changed': `🔄 *Ticket updated:* ${data.title} → ${data.status}`,
'ticket.resolved': `✅ *Ticket resolved:* ${data.title}`,
'ticket.closed': `🔒 *Ticket closed:* ${data.title}`,
'contact.created': `👤 *New contact:* ${data.name} (${data.email})`,
'contact.updated': `✏️ *Contact updated:* ${data.name}`,
'contact.assigned': `📋 *Contact assigned:* ${data.name}`,
'contact.tag_added': `🏷️ *Tag added:* ${data.tag} → ${data.contact_id}`,
'contact.comment_added': `💬 *New comment on contact:* ${data.content}`,
'conversation.created': `💬 *New conversation started*`,
'conversation.closed': `🔒 *Conversation closed*`,
};
await fetch(SLACK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: messages[event] ?? `📡 Blind Agents event: ${event}` }),
});
res.sendStatus(200);
});4. Rich Slack messages with Block Kit (optional)
const block = {
blocks: [
{ type: 'header', text: { type: 'plain_text', text: '🎫 New Bug Report' } },
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Title:*\n${data.title}` },
{ type: 'mrkdwn', text: `*Priority:*\n${data.priority}` },
{ type: 'mrkdwn', text: `*Reporter:*\n${data.contact?.name ?? 'Anonymous'}` },
{ type: 'mrkdwn', text: `*Page:*\n${data.page_url ?? '—'}` },
],
},
{
type: 'actions',
elements: [{
type: 'button',
text: { type: 'plain_text', text: 'View ticket' },
url: `https://app.blindagents.com/tickets/${data.id}`,
style: 'primary',
}],
},
],
};Webhooks — n8n integration
Connect Blind Agents to any service (Slack, Jira, Notion, Google Sheets, HubSpot…) without writing a server.
1. Add a Webhook trigger node in n8n
- Create a new workflow in n8n
- Add a Webhook node → Method: POST → copy the URL
- In Blind Agents → Webhooks → Add webhook, paste that URL and select events
- Copy the signing secret
2. Verify the signature (Code node)
Add a Code node after the Webhook trigger. Store the secret in n8n Credentials as BLIND_AGENTS_SECRET:
const crypto = require('crypto');
const secret = $env.BLIND_AGENTS_SECRET;
const signature = $input.first().headers['x-blindagents-signature'];
const rawBody = JSON.stringify($input.first().body);
const parts = Object.fromEntries(signature.split(',').map(p => p.split('=')));
const hmac = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const isValid = crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(hmac, 'hex'));
const isRecent = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!isValid || !isRecent) throw new Error('Invalid signature — aborting workflow');
return $input.all();3. Send to Slack (Slack node)
Add a Slack node, connect it to your workspace, and set the message text using expressions:
🎫 New ticket: {{ $json.body.data.title }}
Priority: {{ $json.body.data.priority }}
Reporter: {{ $json.body.data.contact.name }}
Page: {{ $json.body.data.page_url }}4. Always respond with 200
Add a Respond to Webhook node at the end — Response Code 200. Without it, Blind Agents marks the delivery as failed and retries.
Other n8n workflow ideas
| Trigger | Action |
|---|---|
| ticket.created | Create Jira / Linear issue |
| ticket.created (high priority) | Send Gmail alert to on-call |
| ticket.resolved | Append row to Google Sheet |
| ticket.status_changed | Update Notion database row |
| contact.created | Sync contact to HubSpot / Mailchimp |
| contact.tag_added | Enroll contact in an email sequence |
| contact.comment_added | Log internal notes to Notion |
| conversation.closed | Trigger a CSAT survey email |
| Any event | POST to Zapier catch hook for further routing |
Self-hosting n8n with Docker
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-e N8N_BASIC_AUTH_ACTIVE=true \
-e N8N_BASIC_AUTH_USER=admin \
-e N8N_BASIC_AUTH_PASSWORD=yourpassword \
-v ~/.n8n:/home/node/.n8n \
docker.n8n.io/n8nio/n8nExpose port 5678 via your domain and use https://your-domain.com/webhook/... as the Blind Agents webhook URL.
Webhook payload reference
Every event sends this JSON body:
{
"id": "evt_01HXYZ...",
"event": "ticket.created",
"created_at": "2026-04-09T03:00:00Z",
"data": { ... }
}The data shape depends on the event type:
Ticket events (ticket.created, ticket.status_changed, ticket.resolved, ticket.closed)
{
"id": "uuid",
"org_id": "uuid",
"contact_id": "uuid or null",
"title": "Button not working on checkout",
"description": "Steps to reproduce...",
"status": "open",
"priority": "high",
"type": "bug",
"page_url": "https://yoursite.com/checkout",
"created_at": "2026-04-09T03:00:00+00:00",
"updated_at": "2026-04-09T03:00:00+00:00"
}For ticket.status_changed, ticket.resolved, and ticket.closed, the payload also includes:
{
"previous_status": "open",
"changed_by_id": "uuid or null"
}Contact events (contact.created, contact.updated, contact.assigned)
{
"contact_id": "uuid",
"email": "[email protected]",
"name": "Jane Doe",
"phone": "1234567890"
}For contact.assigned, the payload also includes:
{
"assigned_to_id": "uuid or null"
}Contact tag events (contact.tag_added, contact.tag_removed)
{
"contact_id": "uuid",
"tag": "vip"
}Contact comment event (contact.comment_added)
{
"contact_id": "uuid",
"comment_id": "uuid",
"author_id": "uuid",
"content": "Followed up via email."
}Supported events
| Event | Fired when |
|---|---|
| ticket.created | A new ticket is submitted |
| ticket.status_changed | Ticket status is updated (any change) |
| ticket.resolved | Ticket is marked resolved |
| ticket.closed | Ticket is closed |
| contact.created | A new contact is registered |
| contact.updated | Contact profile fields are edited |
| contact.assigned | Contact is assigned to a team member |
| contact.tag_added | A tag is added to a contact |
| contact.tag_removed | A tag is removed from a contact |
| contact.comment_added | An internal comment is posted on a contact |
| conversation.created | A new chat conversation starts |
| conversation.closed | A conversation is closed |
Signature verification
Every request includes an X-BlindAgents-Signature header:
t={unix_timestamp},v1={hmac_sha256(secret, "{timestamp}.{raw_body}")}Node.js:
import crypto from 'crypto';
function verifyWebhook(rawBody: string, signature: string, secret: string) {
const parts = Object.fromEntries(signature.split(',').map(p => p.split('=')));
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
const recent = Date.now() / 1000 - Number(parts.t) < 300;
return valid && recent;
}Python:
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature.split(","))
msg = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return (
hmac.compare_digest(parts["v1"], expected)
and abs(time.time() - int(parts["t"])) < 300
)Go:
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strings"
"strconv"
"time"
)
func VerifyWebhook(rawBody []byte, signature, secret string) bool {
parts := map[string]string{}
for _, p := range strings.Split(signature, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 { parts[kv[0]] = kv[1] }
}
ts, _ := strconv.ParseInt(parts["t"], 10, 64)
msg := append([]byte(parts["t"]+"."), rawBody...)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(msg)
expected, _ := hex.DecodeString(hex.EncodeToString(mac.Sum(nil)))
got, _ := hex.DecodeString(parts["v1"])
return hmac.Equal(got, expected) && math.Abs(float64(time.Now().Unix()-ts)) < 300
}Always use constant-time comparison (
timingSafeEqual/hmac.Equal/hmac.compare_digest) and reject requests older than 5 minutes to prevent replay attacks.
License
MIT © Blind Agents
