APIPricingDocsBlogPartnersContact
Back to blog
Tutorial

Setting Up Biometric Webhooks Without a Static IP

Your biometric devices shouldn't need a static IP or a local server. Learn how to set up cloud webhooks that push real-time attendance data to any endpoint β€” with production-ready code in Node.js, Python, and cURL.

PunchConnect TeamΒ·Mar 27, 2026Β·9 min read

Introduction

Your biometric device sits in an office lobby. Your application runs on AWS. The device can't reach your server, and your server can't reach the device. That's the fundamental problem every team hits when they try to get real-time attendance data into their cloud app.

The traditional fix β€” a local server with a static IP running 24/7 β€” was designed for a world where applications lived on-premise. In 2026, your app runs on Vercel, Railway, Render, or a Kubernetes cluster. You need webhooks, not polling loops.

This guide walks you through setting up biometric webhooks with PunchConnect. From zero to receiving live punch events in under 15 minutes. No static IP. No VPN. No local server.

How Biometric Webhooks Work

A webhook is just an HTTP POST request that PunchConnect sends to your server every time something happens β€” an employee punches in, a new fingerprint is enrolled, or a device goes offline. You give PunchConnect a URL, and it pushes events to you in real time.

The key difference from traditional setups: your server doesn't poll. It doesn't connect to the device. It just listens for incoming HTTP requests like any normal web endpoint.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 280" 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 Flow: Device β†’ Cloud β†’ Your App</text>
<!-- Device -->
<rect x="20" y="80" width="160" height="80" rx="12" stroke="#22d3ee" stroke-width="2" fill="none"/>
<text x="100" y="115" text-anchor="middle" fill="#22d3ee" font-size="15" font-family="system-ui">πŸ– Biometric</text>
<text x="100" y="137" text-anchor="middle" fill="#22d3ee" font-size="15" font-family="system-ui">Device</text>
<!-- Arrow 1 -->
<line x1="180" y1="120" x2="260" y2="120" stroke="#64748b" stroke-width="2" marker-end="url(#whArrow)"/>
<text x="220" y="108" text-anchor="middle" fill="#64748b" font-size="13" font-family="system-ui">push</text>
<!-- PunchConnect -->
<rect x="260" y="80" width="180" height="80" rx="12" stroke="#a78bfa" stroke-width="2" fill="none"/>
<text x="350" y="115" text-anchor="middle" fill="#a78bfa" font-size="15" font-family="system-ui">☁️ PunchConnect</text>
<text x="350" y="137" text-anchor="middle" fill="#a78bfa" font-size="15" font-family="system-ui">Cloud</text>
<!-- Arrow 2 -->
<line x1="440" y1="120" x2="520" y2="120" stroke="#64748b" stroke-width="2" marker-end="url(#whArrow)"/>
<text x="480" y="108" text-anchor="middle" fill="#64748b" font-size="13" font-family="system-ui">webhook</text>
<!-- Your App -->
<rect x="520" y="80" width="160" height="80" rx="12" stroke="#34d399" stroke-width="2" fill="none"/>
<text x="600" y="115" text-anchor="middle" fill="#34d399" font-size="15" font-family="system-ui">πŸ’» Your App</text>
<text x="600" y="137" text-anchor="middle" fill="#34d399" font-size="15" font-family="system-ui">(Any Cloud)</text>
<!-- Retry queue -->
<rect x="260" y="200" width="180" height="50" rx="10" stroke="#fb923c" stroke-width="1.5" fill="none"/>
<text x="350" y="230" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">πŸ”„ Retry Queue (72h)</text>
<line x1="350" y1="160" x2="350" y2="200" stroke="#fb923c" stroke-width="1.5" stroke-dasharray="5,4" marker-end="url(#whArrow2)"/>
<text x="375" y="185" fill="#64748b" font-size="12" font-family="system-ui">if 5xx</text>
<defs>
<marker id="whArrow" 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="whArrow2" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#fb923c"/></marker>
</defs>
</svg>

Here's what happens step by step:

1. Employee punches on the biometric device (fingerprint, face, card)
2. Device pushes the event to PunchConnect's protocol engine (outbound from the device β€” no static IP needed)
3. PunchConnect normalizes the raw device data into a clean JSON payload
4. Webhook fires β€” PunchConnect sends an HTTP POST to your registered callback URL
5. Your app processes the event (update database, trigger workflow, send notification)

If your endpoint returns a 5xx error or times out, PunchConnect automatically retries with exponential backoff for up to 72 hours. No punch data is lost.

Step 1: Create Your Webhook Endpoint

Your webhook endpoint is just a normal HTTP route that accepts POST requests. Here's the simplest version in three languages:

Node.js (Express):

Python (Flask):

```python
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/webhook/attendance', methods=['POST'])
def handle_punch():
data = request.json
employee_id = data['employee_id']
timestamp = data['timestamp']
punch_type = data['punch_type']

print(f"Punch received: Employee {employee_id} at {timestamp}")

# Your logic: save to DB, notify manager, update ERP...

return jsonify({"received": True}), 200

if __name__ == '__main__':
app.run(port=3000)
```

Deploy this to any cloud platform β€” Vercel, Railway, Render, Heroku, AWS Lambda, a VPS, anything with a public URL. That URL becomes your callback.

javascript
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhook/attendance', (req, res) => {
const { event_id, employee_id, timestamp, device_serial, punch_type } = req.body;
console.log(`Punch received: Employee ${employee_id} at ${timestamp}`);
// Your logic: save to DB, notify manager, update ERP...
res.status(200).json({ received: true });
});
app.listen(3000, () => console.log('Webhook listener on port 3000'));

Step 2: Register Your Webhook with PunchConnect

Use the PunchConnect API to tell the system where to send events:

The secret field is critical β€” PunchConnect uses it to sign every webhook payload so you can verify authenticity. More on that in the security section.

Response:

bash
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.railway.app/webhook/attendance",
"events": ["attendance.punch"],
"secret": "your-webhook-secret-here"
}'

Step 3: Configure Your Device

Add your biometric device to PunchConnect through the dashboard. The device connects outbound to PunchConnect's cloud β€” no port forwarding, no static IP, no VPN.

<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">Traditional vs. Webhook Architecture</text>
<!-- Traditional side -->
<text x="200" y="55" text-anchor="middle" fill="#ef4444" font-size="15" font-weight="bold" font-family="system-ui">❌ Traditional (Static IP)</text>
<rect x="50" y="70" width="120" height="50" rx="8" stroke="#ef4444" stroke-width="1.5" fill="none"/>
<text x="110" y="100" text-anchor="middle" fill="#ef4444" font-size="14" font-family="system-ui">Device</text>
<line x1="170" y1="95" x2="210" y2="95" stroke="#ef4444" stroke-width="1.5" marker-end="url(#redArr)"/>
<rect x="210" y="70" width="140" height="50" rx="8" stroke="#ef4444" stroke-width="1.5" fill="none"/>
<text x="280" y="95" text-anchor="middle" fill="#ef4444" font-size="13" font-family="system-ui">Local Server</text>
<text x="280" y="110" text-anchor="middle" fill="#ef4444" font-size="11" font-family="system-ui">(static IP, 24/7)</text>
<text x="200" y="145" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">⚠️ IP changes = data loss</text>
<!-- Webhook side -->
<text x="600" y="55" text-anchor="middle" fill="#34d399" font-size="15" font-weight="bold" font-family="system-ui">βœ… Webhook (Cloud)</text>
<rect x="460" y="70" width="120" height="50" rx="8" stroke="#34d399" stroke-width="1.5" fill="none"/>
<text x="520" y="100" text-anchor="middle" fill="#34d399" font-size="14" font-family="system-ui">Device</text>
<line x1="580" y1="95" x2="620" y2="95" stroke="#34d399" stroke-width="1.5" marker-end="url(#greenArr)"/>
<rect x="620" y="70" width="140" height="50" rx="8" stroke="#34d399" stroke-width="1.5" fill="none"/>
<text x="690" y="90" text-anchor="middle" fill="#34d399" font-size="13" font-family="system-ui">PunchConnect</text>
<text x="690" y="105" text-anchor="middle" fill="#34d399" font-size="11" font-family="system-ui">(cloud, managed)</text>
<line x1="690" y1="120" x2="690" y2="160" stroke="#34d399" stroke-width="1.5" marker-end="url(#greenArr)"/>
<rect x="620" y="160" width="140" height="50" rx="8" stroke="#a78bfa" stroke-width="1.5" fill="none"/>
<text x="690" y="185" text-anchor="middle" fill="#a78bfa" font-size="13" font-family="system-ui">Your Cloud App</text>
<text x="690" y="200" text-anchor="middle" fill="#a78bfa" font-size="11" font-family="system-ui">(anywhere)</text>
<text x="600" y="240" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">βœ… No static IP. No local server.</text>
<defs>
<marker id="redArr" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#ef4444"/></marker>
<marker id="greenArr" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#34d399"/></marker>
</defs>
</svg>

The setup takes about 5 minutes per device in the dashboard:

1. Add the device (serial number + model)
2. Point the device's server address to PunchConnect's endpoint
3. The device auto-connects and starts pushing data
4. PunchConnect forwards events to your registered webhook

That's it. No firewall rules. No static IP. No port forwarding.

Step 4: Verify Webhook Signatures

Never trust an incoming webhook blindly. PunchConnect signs every payload using HMAC-SHA256 with your webhook secret. Always verify before processing.

Node.js verification:

Python verification:

```python
import hmac
import hashlib
import json

def verify_signature(payload, signature, secret):
expected = hmac.new(
secret.encode(),
json.dumps(payload, separators=(',', ':')).encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)

@app.route('/webhook/attendance', methods=['POST'])
def handle_punch():
signature = request.headers.get('X-PunchConnect-Signature')
if not verify_signature(request.json, signature, 'your-webhook-secret'):
return jsonify({"error": "Invalid signature"}), 401

# Safe to process
data = request.json
# ... your logic
return jsonify({"received": True}), 200
```

javascript
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
app.post('/webhook/attendance', (req, res) => {
const signature = req.headers['x-punchconnect-signature'];
if (!verifyWebhookSignature(req.body, signature, 'your-webhook-secret')) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Safe to process β€” verified from PunchConnect
const { employee_id, timestamp, punch_type } = req.body;
// ... your logic
res.status(200).json({ received: true });
});

Step 5: Handle Retries and Idempotency

PunchConnect retries failed deliveries with exponential backoff: 1 min β†’ 5 min β†’ 15 min β†’ 1 hour β†’ 4 hours, continuing for up to 72 hours. This means your endpoint might receive the same event twice in edge cases (network timeout after your server processed but before PunchConnect got the 200 response).

Always implement idempotency using the `event_id` field:

In production, store processed event IDs in Redis (with a 72-hour TTL) or a database table. A simple Set works for testing.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 200" 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">Retry Sequence with Exponential Backoff</text>
<!-- Timeline -->
<line x1="60" y1="100" x2="740" y2="100" stroke="#334155" stroke-width="2"/>
<!-- Attempts -->
<circle cx="100" cy="100" r="8" fill="#34d399"/>
<text x="100" y="80" text-anchor="middle" fill="#34d399" font-size="14" font-family="system-ui">1st</text>
<text x="100" y="130" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">0 min</text>
<circle cx="220" cy="100" r="8" fill="#fb923c"/>
<text x="220" y="80" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">2nd</text>
<text x="220" y="130" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">+1 min</text>
<circle cx="340" cy="100" r="8" fill="#fb923c"/>
<text x="340" y="80" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">3rd</text>
<text x="340" y="130" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">+5 min</text>
<circle cx="460" cy="100" r="8" fill="#fb923c"/>
<text x="460" y="80" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">4th</text>
<text x="460" y="130" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">+15 min</text>
<circle cx="580" cy="100" r="8" fill="#fb923c"/>
<text x="580" y="80" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">5th</text>
<text x="580" y="130" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">+1 hr</text>
<circle cx="700" cy="100" r="8" fill="#fb923c"/>
<text x="700" y="80" text-anchor="middle" fill="#fb923c" font-size="14" font-family="system-ui">6th</text>
<text x="700" y="130" text-anchor="middle" fill="#64748b" font-size="12" font-family="system-ui">+4 hrs</text>
<text x="400" y="170" text-anchor="middle" fill="#64748b" font-size="14" font-family="system-ui">Retries continue for up to 72 hours β€” no data lost</text>
</svg>

javascript
const processedEvents = new Set(); // Use Redis or DB in production
app.post('/webhook/attendance', (req, res) => {
const { event_id } = req.body;
if (processedEvents.has(event_id)) {
return res.status(200).json({ received: true, duplicate: true });
}
processedEvents.add(event_id);
// Process the event...
res.status(200).json({ received: true });
});

Testing Your Webhook Locally

Before deploying, test locally using PunchConnect's test endpoint or a tool like ngrok to expose your local server:

The test endpoint simulates a real punch event, so you can verify your entire pipeline without a physical device.

bash
# Start your local server
node server.js
# Expose it with ngrok
ngrok http 3000
# β†’ https://abc123.ngrok.io
# Register the ngrok URL as 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://abc123.ngrok.io/webhook/attendance", "events": ["attendance.punch"]}'
# Trigger a test event
curl -X POST https://api.punchconnect.com/v1/webhooks/wh_abc123/test \
-H "Authorization: Bearer YOUR_API_KEY"

Webhook Payload Reference

Every attendance webhook delivers a consistent JSON structure:

Key fields:
- `event_id` β€” unique per event, use for idempotency
- `punch_type` β€” check_in, check_out, break_start, break_end
- `verification_method` β€” fingerprint, face, card, pin
- `signature` β€” HMAC-SHA256 of the payload for verification

json
{
"event_id": "evt_7f8a9b2c",
"event_type": "attendance.punch",
"timestamp": "2026-03-27T08:02:14Z",
"device": {
"serial": "CWE2203900045",
"name": "Lobby Terminal",
"site": "HQ Building A"
},
"employee": {
"id": "142",
"name": "Sarah Chen"
},
"punch_type": "check_in",
"verification_method": "fingerprint",
"webhook_id": "wh_abc123",
"signature": "a1b2c3d4e5f6..."
}

Production Checklist

Before going live, make sure you've covered these:

- βœ… HTTPS endpoint β€” PunchConnect won't deliver to plain HTTP in production
- βœ… Signature verification β€” validate every request with HMAC-SHA256
- βœ… Idempotency β€” handle duplicate deliveries using event_id
- βœ… 200 response within 10 seconds β€” return quickly, process async if needed
- βœ… Logging β€” record every webhook for debugging (PunchConnect dashboard also shows delivery logs)
- βœ… Alerting β€” monitor for delivery failures in PunchConnect's webhook logs

If your endpoint needs more than 10 seconds to process, return 200 immediately and process the event asynchronously in a background queue (SQS, BullMQ, Celery).

Frequently Asked Questions

Do I need a static IP to receive webhooks?

No. That's the entire point. Your webhook endpoint just needs a public URL β€” any cloud platform (AWS, Railway, Vercel, Render) provides this automatically. The device connects outbound to PunchConnect, and PunchConnect connects outbound to your URL. No inbound connections required.

What happens if my server is down when a punch happens?

PunchConnect queues the event and retries with exponential backoff for up to 72 hours. Once your server comes back online, all queued events are delivered in order. Zero data loss.

Can I receive webhooks on a serverless function?

Yes. PunchConnect webhooks work with AWS Lambda, Vercel Functions, Cloudflare Workers, Google Cloud Functions β€” any platform that can receive HTTP POST requests. Just make sure your function responds within 10 seconds.

How do I debug failed webhook deliveries?

The PunchConnect dashboard shows a complete delivery log for every webhook: request body, response code, response time, and retry history. You can also replay any failed delivery with one click.

Can I subscribe to multiple event types?

Yes. Beyond attendance.punch, you can subscribe to device.online, device.offline, employee.enrolled, and more. Register each event type when creating the webhook, or create separate webhooks for different event types.

---

*Ready to receive real-time attendance data in your cloud app? Start your free trial β€” first device free, setup in under 15 minutes.*

Related articles

Setting Up Biometric Webhooks Without a Static IP | PunchConnect