Designing a Biometric Attendance API That Developers Actually Want to Use
Most biometric APIs are afterthoughts bolted onto hardware SDKs. Here's how we designed PunchConnect's REST API from first principles β with webhooks, idempotency, and real-time delivery that developers expect from modern infrastructure.
Introduction
You've built an HRMS. Payroll runs on attendance data. Now you need to get punch events from 200 biometric devices into your system β reliably, in real time, without losing a single record. That's the API design problem nobody talks about until they're debugging missing punches at 2 AM.
Most biometric "APIs" are vendor SDKs dressed in REST clothing. They expose raw device registers, require polling loops, and break when a device goes offline for 30 seconds. They weren't designed for cloud-native applications.
When we built PunchConnect's API, we started from the other end: what would a developer building an ERP integration expect? The answer looked a lot like Stripe, Twilio, and GitHub β not like a hardware manual.
Three Design Principles We Stole From the Best
We studied the APIs developers actually enjoy using. Three patterns kept showing up:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 320" fill="none" style="width:100%;max-width:800px;">
<text x="400" y="30" text-anchor="middle" fill="#94a3b8" font-size="17" font-weight="bold" font-family="system-ui">API Design Principles</text>
<!-- Predictable -->
<rect x="30" y="60" width="220" height="120" rx="12" stroke="#22d3ee" stroke-width="2" fill="none"/>
<text x="140" y="90" text-anchor="middle" fill="#22d3ee" font-size="17" font-weight="bold" font-family="system-ui">π― Predictable</text>
<text x="140" y="115" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">Consistent naming</text>
<text x="140" y="135" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">Uniform error codes</text>
<text x="140" y="155" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">Standard response shape</text>
<!-- Real-time -->
<rect x="290" y="60" width="220" height="120" rx="12" stroke="#a78bfa" stroke-width="2" fill="none"/>
<text x="400" y="90" text-anchor="middle" fill="#a78bfa" font-size="17" font-weight="bold" font-family="system-ui">β‘ Real-Time</text>
<text x="400" y="115" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">Webhooks, not polling</text>
<text x="400" y="135" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">Events pushed instantly</text>
<text x="400" y="155" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">Sub-second delivery</text>
<!-- Resilient -->
<rect x="550" y="60" width="220" height="120" rx="12" stroke="#34d399" stroke-width="2" fill="none"/>
<text x="660" y="90" text-anchor="middle" fill="#34d399" font-size="17" font-weight="bold" font-family="system-ui">π‘οΈ Resilient</text>
<text x="660" y="115" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">Idempotency keys</text>
<text x="660" y="135" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">At-least-once delivery</text>
<text x="660" y="155" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">72-hour retry window</text>
<!-- Bottom summary -->
<rect x="130" y="220" width="540" height="70" rx="12" stroke="#64748b" stroke-width="1.5" fill="none" stroke-dasharray="5,4"/>
<text x="400" y="250" text-anchor="middle" fill="#e2e8f0" font-size="15" font-weight="bold" font-family="system-ui">Result: An API that works like Stripe β for biometric hardware</text>
<text x="400" y="272" text-anchor="middle" fill="#64748b" font-size="14" font-family="system-ui">24,000+ employees on AgriWise prove it scales</text>
<!-- Arrows -->
<line x1="140" y1="180" x2="250" y2="220" stroke="#64748b" stroke-width="1.5"/>
<line x1="400" y1="180" x2="400" y2="220" stroke="#64748b" stroke-width="1.5"/>
<line x1="660" y1="180" x2="550" y2="220" stroke="#64748b" stroke-width="1.5"/>
</svg>
1. Be predictable. Every endpoint follows the same naming convention. Every response has the same shape. Every error returns a structured object with a machine-readable code and a human-readable message. No surprises.
2. Be real-time. Webhooks for events, not polling. When an employee punches in at the door, your application knows within seconds β not whenever your cron job runs.
3. Be resilient. Every mutating operation accepts an idempotency key. Every webhook delivery retries with exponential backoff. Every piece of data can be replayed without duplicates.
The Webhook Delivery Pipeline
The hardest part of a biometric attendance API isn't the REST endpoints β it's guaranteeing delivery of punch events when the real world is unreliable. Devices go offline. Networks drop. Servers restart during deployments.
PunchConnect guarantees at-least-once delivery for every webhook event. Here's exactly how:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 380" fill="none" style="width:100%;max-width:800px;">
<text x="400" y="25" text-anchor="middle" fill="#94a3b8" font-size="17" font-weight="bold" font-family="system-ui">Webhook Delivery Pipeline</text>
<!-- Step 1: Punch -->
<rect x="20" y="60" width="150" height="65" rx="10" stroke="#22d3ee" stroke-width="2" fill="none"/>
<text x="95" y="85" text-anchor="middle" fill="#22d3ee" font-size="15" font-family="system-ui">π Punch</text>
<text x="95" y="105" text-anchor="middle" fill="#64748b" font-size="13" font-family="system-ui">Event occurs</text>
<!-- Arrow -->
<line x1="170" y1="92" x2="220" y2="92" stroke="#64748b" stroke-width="2" marker-end="url(#apiArrow)"/>
<!-- Step 2: Queue -->
<rect x="220" y="60" width="150" height="65" rx="10" stroke="#a78bfa" stroke-width="2" fill="none"/>
<text x="295" y="85" text-anchor="middle" fill="#a78bfa" font-size="15" font-family="system-ui">π₯ Durable Queue</text>
<text x="295" y="105" text-anchor="middle" fill="#64748b" font-size="13" font-family="system-ui">Persisted to disk</text>
<!-- Arrow -->
<line x1="370" y1="92" x2="420" y2="92" stroke="#64748b" stroke-width="2" marker-end="url(#apiArrow)"/>
<!-- Step 3: Deliver -->
<rect x="420" y="60" width="150" height="65" rx="10" stroke="#34d399" stroke-width="2" fill="none"/>
<text x="495" y="85" text-anchor="middle" fill="#34d399" font-size="15" font-family="system-ui">π€ HTTP POST</text>
<text x="495" y="105" text-anchor="middle" fill="#64748b" font-size="13" font-family="system-ui">To your callback</text>
<!-- Arrow -->
<line x1="570" y1="92" x2="620" y2="92" stroke="#64748b" stroke-width="2" marker-end="url(#apiArrow)"/>
<!-- Step 4: Ack -->
<rect x="620" y="60" width="150" height="65" rx="10" stroke="#fbbf24" stroke-width="2" fill="none"/>
<text x="695" y="85" text-anchor="middle" fill="#fbbf24" font-size="15" font-family="system-ui">β
2xx = Done</text>
<text x="695" y="105" text-anchor="middle" fill="#64748b" font-size="13" font-family="system-ui">Marked delivered</text>
<!-- Retry path -->
<text x="400" y="165" text-anchor="middle" fill="#fb923c" font-size="15" font-weight="bold" font-family="system-ui">If your server returns 5xx or times out:</text>
<!-- Retry boxes -->
<rect x="40" y="190" width="100" height="50" rx="8" stroke="#fb923c" stroke-width="1.5" fill="none"/>
<text x="90" y="212" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">1 min</text>
<text x="90" y="230" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">Retry 1</text>
<line x1="140" y1="215" x2="170" y2="215" stroke="#fb923c" stroke-width="1.5" marker-end="url(#retryArrow)"/>
<rect x="170" y="190" width="100" height="50" rx="8" stroke="#fb923c" stroke-width="1.5" fill="none"/>
<text x="220" y="212" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">5 min</text>
<text x="220" y="230" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">Retry 2</text>
<line x1="270" y1="215" x2="300" y2="215" stroke="#fb923c" stroke-width="1.5" marker-end="url(#retryArrow)"/>
<rect x="300" y="190" width="100" height="50" rx="8" stroke="#fb923c" stroke-width="1.5" fill="none"/>
<text x="350" y="212" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">30 min</text>
<text x="350" y="230" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">Retry 3</text>
<line x1="400" y1="215" x2="430" y2="215" stroke="#fb923c" stroke-width="1.5" marker-end="url(#retryArrow)"/>
<rect x="430" y="190" width="100" height="50" rx="8" stroke="#fb923c" stroke-width="1.5" fill="none"/>
<text x="480" y="212" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">2 hours</text>
<text x="480" y="230" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">Retry 4</text>
<line x1="530" y1="215" x2="560" y2="215" stroke="#fb923c" stroke-width="1.5" marker-end="url(#retryArrow)"/>
<rect x="560" y="190" width="100" height="50" rx="8" stroke="#fb923c" stroke-width="1.5" fill="none"/>
<text x="610" y="212" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">12 hours</text>
<text x="610" y="230" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">Retry 5</text>
<line x1="660" y1="215" x2="690" y2="215" stroke="#fb923c" stroke-width="1.5" marker-end="url(#retryArrow)"/>
<rect x="690" y="190" width="80" height="50" rx="8" stroke="#fb923c" stroke-width="1.5" fill="none"/>
<text x="730" y="212" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">72h</text>
<text x="730" y="230" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">Final</text>
<!-- Failed path -->
<line x1="730" y1="240" x2="730" y2="280" stroke="#ef4444" stroke-width="1.5" marker-end="url(#failArrow)"/>
<rect x="620" y="280" width="180" height="50" rx="8" stroke="#ef4444" stroke-width="1.5" fill="none"/>
<text x="710" y="302" text-anchor="middle" fill="#ef4444" font-size="14" font-family="system-ui">β Marked failed</text>
<text x="710" y="320" text-anchor="middle" fill="#64748b" font-size="13" font-family="system-ui">Email notification sent</text>
<defs>
<marker id="apiArrow" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/></marker>
<marker id="retryArrow" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#fb923c"/></marker>
<marker id="failArrow" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#ef4444"/></marker>
</defs>
</svg>
1. Punch event occurs on the biometric device (fingerprint, face, card)
2. Event is persisted to a durable queue β it's safe even if the entire system restarts
3. Delivery worker sends an HTTP POST to your registered callback URL
4. Your server responds with 2xx β event marked as delivered
5. If delivery fails (5xx, timeout, network error) β exponential backoff retries over 72 hours
6. After all retries exhausted β event marked as failed, email notification sent to your account
This is the same pattern Stripe uses for payment webhooks. The difference: our payloads contain punch events instead of charges.
What the Webhook Payload Looks Like
Every webhook delivers a clean, normalized JSON object. No raw device registers. No vendor-specific formats. Just the data your application needs:
Notice what's absent: no raw TCP register values, no hex-encoded timestamps, no device-specific command codes. PunchConnect normalizes all of that before it reaches your webhook. You work with clean employee names, badge numbers, and ISO timestamps.
{"event_id": "evt_a1b2c3d4e5f6","event_type": "attendance.punch","timestamp": "2026-03-28T08:01:23Z","device": {"id": "dev_9x8y7z","serial_number": "BKRK233600045","name": "Main Entrance","site": "Headquarters"},"employee": {"id": "emp_4k5l6m","badge_number": "1042","name": "Sarah Chen"},"punch": {"direction": "in","method": "fingerprint","confidence": 0.97,"local_time": "2026-03-28T09:01:23+01:00"},"metadata": {"deduplicated": false,"delivery_attempt": 1}}
Handling Webhook Events in Your Application
Here's how you receive and process attendance webhooks in your backend:
Python (Flask)
import hmacimport hashlibfrom flask import Flask, request, jsonifyapp = Flask(__name__)WEBHOOK_SECRET = "whsec_your_signing_secret"def verify_signature(payload, signature):expected = hmac.new(WEBHOOK_SECRET.encode(),payload,hashlib.sha256).hexdigest()return hmac.compare_digest(f"sha256={expected}", signature)@app.route("/webhooks/attendance", methods=["POST"])def handle_attendance():signature = request.headers.get("X-PunchConnect-Signature")if not verify_signature(request.data, signature):return jsonify({"error": "Invalid signature"}), 401event = request.jsonevent_type = event["event_type"]if event_type == "attendance.punch":employee = event["employee"]["badge_number"]direction = event["punch"]["direction"]timestamp = event["timestamp"]# Insert into your attendance tableprint(f"Employee {employee} punched {direction} at {timestamp}")return jsonify({"received": True}), 200
Node.js (Express)
const express = require('express');const crypto = require('crypto');const app = express();const WEBHOOK_SECRET = 'whsec_your_signing_secret';app.post('/webhooks/attendance', express.raw({ type: 'application/json' }), (req, res) => {const signature = req.headers['x-punchconnect-signature'];const expected = `sha256=${crypto.createHmac('sha256', WEBHOOK_SECRET).update(req.body).digest('hex')}`;if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {return res.status(401).json({ error: 'Invalid signature' });}const event = JSON.parse(req.body);if (event.event_type === 'attendance.punch') {const { badge_number } = event.employee;const { direction } = event.punch;console.log(`Employee ${badge_number} punched ${direction} at ${event.timestamp}`);// Save to your database}res.json({ received: true });});app.listen(3000, () => console.log('Webhook listener running on :3000'));
cURL β Register Your Webhook
curl -X POST https://api.punchconnect.com/v1/webhooks \-H "Authorization: Bearer YOUR_API_KEY" \-H "Content-Type: application/json" \-d '{"url": "https://your-app.com/webhooks/attendance","events": ["attendance.punch", "device.status"],"secret": "whsec_your_signing_secret"}'
Idempotency: Why Every Command Is Safe to Retry
Network failures happen. Your request times out. Did the device receive the command or not? Without idempotency, you're guessing.
PunchConnect accepts an Idempotency-Key header on every mutating endpoint. Send the same request with the same key within 24 hours, and you get the cached response β no duplicate side effects.
If your network drops before you receive the response, retry with the exact same Idempotency-Key. PunchConnect recognizes the duplicate and returns the original response. The device won't receive a double sync.
This matters most for device commands β syncing employee lists, updating access rules, triggering door locks. Without idempotency, a single retry could enroll employees twice or trigger a door open command multiple times.
# Send a sync command β safe to retrycurl -X POST https://api.punchconnect.com/v1/devices/dev_9x8y7z/commands \-H "Authorization: Bearer YOUR_API_KEY" \-H "Idempotency-Key: sync-employees-2026-03-28-001" \-H "Content-Type: application/json" \-d '{"command": "sync_employees","payload": {"employees": [{"badge_number": "1042", "name": "Sarah Chen"},{"badge_number": "1043", "name": "James Okoro"}]}}'
Rate Limiting That Doesn't Surprise You
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 260" fill="none" style="width:100%;max-width:800px;">
<text x="400" y="25" text-anchor="middle" fill="#94a3b8" font-size="17" font-weight="bold" font-family="system-ui">Rate Limiting: Clear Headers, Generous Defaults</text>
<!-- Request flow -->
<rect x="30" y="60" width="200" height="70" rx="10" stroke="#22d3ee" stroke-width="2" fill="none"/>
<text x="130" y="90" text-anchor="middle" fill="#22d3ee" font-size="15" font-family="system-ui">Your API Call</text>
<text x="130" y="110" text-anchor="middle" fill="#64748b" font-size="13" font-family="system-ui">GET /v1/attendance</text>
<line x1="230" y1="95" x2="300" y2="95" stroke="#64748b" stroke-width="2" marker-end="url(#rlArrow)"/>
<!-- PunchConnect -->
<rect x="300" y="60" width="200" height="70" rx="10" stroke="#a78bfa" stroke-width="2" fill="none"/>
<text x="400" y="90" text-anchor="middle" fill="#a78bfa" font-size="15" font-family="system-ui">PunchConnect</text>
<text x="400" y="110" text-anchor="middle" fill="#64748b" font-size="13" font-family="system-ui">Rate limiter checks</text>
<!-- Under limit -->
<line x1="500" y1="80" x2="570" y2="65" stroke="#34d399" stroke-width="2" marker-end="url(#rlArrowG)"/>
<rect x="570" y="40" width="200" height="50" rx="10" stroke="#34d399" stroke-width="1.5" fill="none"/>
<text x="670" y="60" text-anchor="middle" fill="#34d399" font-size="14" font-family="system-ui">β
200 + Data</text>
<text x="670" y="78" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">X-RateLimit-Remaining: 847</text>
<!-- Over limit -->
<line x1="500" y1="110" x2="570" y2="130" stroke="#ef4444" stroke-width="2" marker-end="url(#rlArrowR)"/>
<rect x="570" y="110" width="200" height="50" rx="10" stroke="#ef4444" stroke-width="1.5" fill="none"/>
<text x="670" y="130" text-anchor="middle" fill="#ef4444" font-size="14" font-family="system-ui">β 429 Too Many</text>
<text x="670" y="148" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">Retry-After: 60</text>
<!-- Defaults box -->
<rect x="130" y="170" width="540" height="70" rx="10" stroke="#64748b" stroke-width="1.5" fill="none" stroke-dasharray="5,4"/>
<text x="400" y="195" text-anchor="middle" fill="#e2e8f0" font-size="15" font-weight="bold" font-family="system-ui">Default Limits</text>
<text x="400" y="220" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">REST: 1,000 calls/device/day β’ Inbound webhooks (device β cloud): Unlimited</text>
<defs>
<marker id="rlArrow" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/></marker>
<marker id="rlArrowG" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#34d399"/></marker>
<marker id="rlArrowR" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#ef4444"/></marker>
</defs>
</svg>
Every response includes rate limit headers:
When you hit the limit, you get a 429 Too Many Requests with a Retry-After header telling you exactly how many seconds to wait. No guessing. No silent failures.
The defaults are generous on purpose. 1,000 REST calls per device per day covers most integration patterns. Inbound webhooks from devices to PunchConnect are unlimited β we never throttle your hardware.
X-RateLimit-Limit: 1000X-RateLimit-Remaining: 847X-RateLimit-Reset: 1711584000
Data Normalization: Cleaning Up the Mess
Here's what most teams don't realize until they're deep into a biometric integration: device data is messy. Duplicate punches when someone scans twice. Timestamps that drift by minutes across devices. Occasionally, completely garbage records from firmware bugs.
PunchConnect normalizes everything before it reaches your webhook:
- Duplicate detection β If the same employee punches on the same device within a configurable window (default: 60 seconds), the duplicate is filtered. You get one clean event.
- Timestamp normalization β Device clocks drift. PunchConnect reconciles device local time against server time and delivers UTC timestamps you can trust.
- Direction inference β Some devices don't track in/out. PunchConnect uses configurable rules (first punch = in, last punch = out, or alternating) to infer direction.
- Bad data filtering β Malformed records, zero-timestamps, and unregistered badge numbers are caught and logged without polluting your webhook stream.
You never see the raw data. That's the point.
# What the device actually sends (raw, messy):# badge=1042, timestamp=1711609283, status=0, verify=1, reserved=0# What your webhook receives (clean, normalized):{"employee": {"badge_number": "1042", "name": "Sarah Chen"},"punch": {"direction": "in", "method": "fingerprint"},"timestamp": "2026-03-28T09:01:23Z"}
REST Endpoints for Everything Else
Webhooks handle the real-time flow. For everything else β querying historical attendance, managing devices, syncing employees β there's a standard REST API:
Every list endpoint supports pagination (page, per_page), filtering, and sorting. Every response follows the same envelope:
Same shape for attendance, devices, employees, webhooks, commands. Learn it once, use it everywhere.
# Get attendance records for a date rangecurl https://api.punchconnect.com/v1/attendance \-H "Authorization: Bearer YOUR_API_KEY" \-G \-d "start_date=2026-03-01" \-d "end_date=2026-03-28" \-d "device_id=dev_9x8y7z"# List all devices and their statuscurl https://api.punchconnect.com/v1/devices \-H "Authorization: Bearer YOUR_API_KEY"# Get a single device's detailscurl https://api.punchconnect.com/v1/devices/dev_9x8y7z \-H "Authorization: Bearer YOUR_API_KEY"
What We Learned From 24,000+ Employees
Running PunchConnect across 50+ sites for AgriWise's 24,000+ employees taught us things no amount of API design theory could:
Clock drift is real. Devices across time zones and unreliable networks drift. Some by seconds, others by minutes. UTC normalization isn't optional β it's survival.
Offline buffering is mandatory. Rural agricultural sites lose internet for hours. Devices buffer punches locally. When connectivity returns, PunchConnect processes the backlog in order. Your webhook receives events with accurate timestamps, not the time they arrived at the server.
Batch operations matter. When you're syncing 5,000 employee records across 50 devices, individual API calls don't cut it. Batch endpoints with progress tracking and partial failure handling are essential.
Error messages should be copy-pasteable. When a developer hits an error, the response should contain everything they need to fix it β including a link to the relevant documentation.
Frequently Asked Questions
How fast are webhook deliveries?
Typically under 2 seconds from the moment the employee punches to your webhook receiving the event. The bottleneck is usually the device's push interval, not PunchConnect's processing.
What happens if my webhook endpoint is down for hours?
Events queue up and retry with exponential backoff over 72 hours. When your server comes back online, you'll receive all missed events in order. Nothing is lost.
Can I replay historical webhook events?
Yes. The webhook management API lets you replay events for a given time range. Useful for backfilling data after fixing a bug in your handler.
Do I need to handle duplicate webhook deliveries?
With at-least-once delivery, it's possible to receive the same event twice (e.g., if your server responded slowly and we retried). Use the event_id field for deduplication β it's unique per event and stable across retries.
How do I test webhooks during development?
Register a webhook URL pointing to a tool like ngrok or webhook.site. PunchConnect will deliver real events from your test devices to your local machine.
---
Building a biometric integration? PunchConnect gives you the API you'd design yourself β if you had six months and 24,000 employees to test against. Start a 7-day free trial and receive your first webhook in under 5 minutes. No credit card. No static IP. Just clean data, delivered reliably.