APIPricingDocsBlogPartnersContact
Back to blog
Integration

Real-Time Attendance in Your ERP: Webhooks, Middleware, and 20 Lines of Code

Pipe biometric punch events into Odoo, SAP, ERPNext, or your custom HRMS in real time. No polling, no batch imports, no CSV uploads. Just a webhook, a serverless function, and 20 lines of code.

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

Introduction

Your biometric devices collect punches. Your ERP calculates payroll. Between those two systems sits a gap that costs companies hours of manual data entry every week. Some IT teams export CSVs at midnight. Others run cron jobs that poll devices every 5 minutes. A few have given up and hired someone to type in attendance records manually.

None of that is necessary. With a webhook and a 20-line middleware function, every punch flows into your ERP the moment a finger touches the scanner.

This guide shows you exactly how β€” with production-ready code for Odoo, SAP, ERPNext, and custom HRMS systems.

Why Batch Imports Are Killing Your Payroll Accuracy

The traditional approach looks something like this: export attendance data from the device software, save it as a CSV, upload it to the ERP, hope nothing broke during the mapping. Teams do this daily, sometimes twice a day.

The problems stack up fast:

- Data lag. A punch at 8:47 AM might not reach the ERP until the midnight batch. Overtime calculations are always a day behind.
- Missing records. Device goes offline during the export window? Those punches vanish. You discover the gap when payroll is already submitted.
- Manual errors. Someone maps employee ID 1042 to the wrong Odoo record. One bad mapping cascades into wrong paychecks.
- No audit trail. When did that punch actually arrive in the ERP? Nobody knows. It was in a CSV somewhere.

Real-time integration eliminates all four problems. Every punch arrives in the ERP within seconds, with a verifiable timestamp and a clean audit trail.

<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="30" text-anchor="middle" fill="#94a3b8" font-size="17" font-weight="bold" font-family="system-ui">Batch Import vs Real-Time Webhook</text>
<!-- Batch side -->
<text x="200" y="65" text-anchor="middle" fill="#ef4444" font-size="15" font-weight="bold" font-family="system-ui">❌ Traditional Batch Import</text>
<rect x="40" y="80" width="320" height="280" rx="12" stroke="#ef4444" stroke-width="1.5" fill="none" stroke-dasharray="5,4"/>
<rect x="60" y="100" width="130" height="40" rx="8" stroke="#64748b" stroke-width="1.5" fill="none"/>
<text x="125" y="125" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">Device Software</text>
<text x="200" y="125" text-anchor="middle" fill="#64748b" font-size="14" font-family="system-ui">β†’</text>
<rect x="220" y="100" width="120" height="40" rx="8" stroke="#64748b" stroke-width="1.5" fill="none"/>
<text x="280" y="125" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">CSV Export</text>
<text x="280" y="165" text-anchor="middle" fill="#64748b" font-size="14" font-family="system-ui">↓ (hours later)</text>
<rect x="130" y="180" width="150" height="40" rx="8" stroke="#64748b" stroke-width="1.5" fill="none"/>
<text x="205" y="205" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">Manual Upload</text>
<text x="205" y="245" text-anchor="middle" fill="#64748b" font-size="14" font-family="system-ui">↓ (error-prone)</text>
<rect x="155" y="260" width="100" height="40" rx="8" stroke="#ef4444" stroke-width="1.5" fill="none"/>
<text x="205" y="285" text-anchor="middle" fill="#ef4444" font-size="14" font-family="system-ui">ERP</text>
<text x="205" y="330" text-anchor="middle" fill="#ef4444" font-size="14" font-family="system-ui">⏱ 4-24 hour delay</text>
<text x="205" y="350" text-anchor="middle" fill="#ef4444" font-size="14" font-family="system-ui">πŸ“‰ Missing records</text>
<!-- Real-time side -->
<text x="600" y="65" text-anchor="middle" fill="#22d3ee" font-size="15" font-weight="bold" font-family="system-ui">βœ… Real-Time Webhook</text>
<rect x="440" y="80" width="320" height="280" rx="12" stroke="#22d3ee" stroke-width="1.5" fill="none"/>
<rect x="490" y="110" width="130" height="40" rx="8" stroke="#22d3ee" stroke-width="1.5" fill="none"/>
<text x="555" y="135" text-anchor="middle" fill="#22d3ee" font-size="14" font-family="system-ui">Biometric Device</text>
<text x="600" y="175" text-anchor="middle" fill="#22d3ee" font-size="14" font-family="system-ui">↓ instant</text>
<rect x="490" y="190" width="130" height="40" rx="8" stroke="#a78bfa" stroke-width="1.5" fill="none"/>
<text x="555" y="215" text-anchor="middle" fill="#a78bfa" font-size="14" font-family="system-ui">PunchConnect</text>
<text x="600" y="255" text-anchor="middle" fill="#22d3ee" font-size="14" font-family="system-ui">↓ webhook (< 1s)</text>
<rect x="490" y="270" width="130" height="40" rx="8" stroke="#34d399" stroke-width="1.5" fill="none"/>
<text x="555" y="295" text-anchor="middle" fill="#34d399" font-size="14" font-family="system-ui">Your ERP</text>
<text x="600" y="340" text-anchor="middle" fill="#34d399" font-size="14" font-family="system-ui">⚑ Sub-second delivery</text>
<text x="600" y="358" text-anchor="middle" fill="#34d399" font-size="14" font-family="system-ui">πŸ“Š Zero data loss</text>
</svg>

The Architecture: Device β†’ PunchConnect β†’ Middleware β†’ ERP

The integration pattern is the same regardless of which ERP you use:

1. Biometric device registers a punch (fingerprint, face, RFID)
2. PunchConnect receives the event and pushes it as a webhook to your endpoint
3. Your middleware (a serverless function or lightweight server) receives the webhook
4. Middleware maps and writes the attendance record to your ERP's API

The middleware is where the magic happens. It's typically 20-50 lines of code β€” a simple HTTP handler that transforms a PunchConnect webhook payload into your ERP's attendance API format.

The Webhook Payload You'll Receive

Every punch event arrives as a JSON POST to your configured webhook URL:

The event_id is your idempotency key β€” use it to deduplicate in case of retries.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 300" 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">Middleware Integration Flow</text>
<!-- Step boxes -->
<rect x="30" y="70" width="150" height="70" rx="10" stroke="#22d3ee" stroke-width="2" fill="none"/>
<text x="105" y="98" text-anchor="middle" fill="#22d3ee" font-size="15" font-weight="bold" font-family="system-ui">1. Receive</text>
<text x="105" y="120" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">POST /webhook</text>
<line x1="180" y1="105" x2="220" y2="105" stroke="#64748b" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="220" y="70" width="150" height="70" rx="10" stroke="#a78bfa" stroke-width="2" fill="none"/>
<text x="295" y="98" text-anchor="middle" fill="#a78bfa" font-size="15" font-weight="bold" font-family="system-ui">2. Verify</text>
<text x="295" y="120" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">HMAC signature</text>
<line x1="370" y1="105" x2="410" y2="105" stroke="#64748b" stroke-width="1.5"/>
<rect x="410" y="70" width="150" height="70" rx="10" stroke="#f59e0b" stroke-width="2" fill="none"/>
<text x="485" y="98" text-anchor="middle" fill="#f59e0b" font-size="15" font-weight="bold" font-family="system-ui">3. Map</text>
<text x="485" y="120" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">ID β†’ ERP employee</text>
<line x1="560" y1="105" x2="600" y2="105" stroke="#64748b" stroke-width="1.5"/>
<rect x="600" y="70" width="170" height="70" rx="10" stroke="#34d399" stroke-width="2" fill="none"/>
<text x="685" y="98" text-anchor="middle" fill="#34d399" font-size="15" font-weight="bold" font-family="system-ui">4. Write</text>
<text x="685" y="120" text-anchor="middle" fill="#94a3b8" font-size="14" font-family="system-ui">Create ERP record</text>
<!-- Error handling -->
<rect x="220" y="190" width="340" height="80" rx="10" stroke="#64748b" stroke-width="1.5" fill="none" stroke-dasharray="5,4"/>
<text x="390" y="218" text-anchor="middle" fill="#94a3b8" font-size="15" font-weight="bold" font-family="system-ui">On failure: return 500 β†’ PunchConnect retries</text>
<text x="390" y="242" text-anchor="middle" fill="#64748b" font-size="14" font-family="system-ui">Exponential backoff β€’ 72-hour retry window β€’ At-least-once</text>
<line x1="485" y1="140" x2="390" y2="190" stroke="#64748b" stroke-width="1" stroke-dasharray="4,3"/>
</svg>

json
{
"event": "attendance.punch",
"device_serial": "CZKE2241600045",
"employee_id": "1042",
"timestamp": "2026-03-28T08:47:12Z",
"direction": "in",
"method": "fingerprint",
"event_id": "evt_a8f3k2x9m1"
}

Integration #1: Odoo (JSON-RPC)

Odoo uses JSON-RPC for its API. Here's a complete middleware that receives PunchConnect webhooks and creates attendance records in Odoo:

Deploy this on Railway, Render, or any serverless platform β€” it handles the entire Odoo integration. We've seen customers get this working in under an hour.

> Pro tip: Map employee_id from PunchConnect to Odoo's barcode field on hr.employee. It's the cleanest mapping β€” no custom fields needed.

python
# odoo_middleware.py β€” PunchConnect β†’ Odoo attendance
import xmlrpc.client
from flask import Flask, request, jsonify
app = Flask(__name__)
# Odoo connection
ODOO_URL = "https://your-odoo.com"
ODOO_DB = "your-database"
ODOO_USER = "admin"
ODOO_PASS = "your-api-key"
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")
uid = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common").authenticate(
ODOO_DB, ODOO_USER, ODOO_PASS, {}
)
@app.route("/webhook", methods=["POST"])
def handle_punch():
data = request.json
# Find Odoo employee by badge ID
employee = models.execute_kw(
ODOO_DB, uid, ODOO_PASS,
"hr.employee", "search_read",
[[["barcode", "=", data["employee_id"]]]],
{"fields": ["id"], "limit": 1}
)
if not employee:
return jsonify({"error": "employee not found"}), 404
# Create attendance record
if data["direction"] == "in":
models.execute_kw(
ODOO_DB, uid, ODOO_PASS,
"hr.attendance", "create",
[{"employee_id": employee[0]["id"],
"check_in": data["timestamp"]}]
)
else:
# Find open attendance and close it
open_att = models.execute_kw(
ODOO_DB, uid, ODOO_PASS,
"hr.attendance", "search",
[[["employee_id", "=", employee[0]["id"]],
["check_out", "=", False]]],
{"limit": 1}
)
if open_att:
models.execute_kw(
ODOO_DB, uid, ODOO_PASS,
"hr.attendance", "write",
[open_att, {"check_out": data["timestamp"]}]
)
return jsonify({"status": "ok"}), 200

Integration #2: SAP (OData / RFC)

SAP integrations follow the same pattern but use SAP's OData API or RFC calls. The key difference is authentication β€” SAP requires more setup.

For on-premise SAP: Use SAP Cloud Integration (CPI) as the middleware layer. CPI can receive PunchConnect webhooks via HTTPS and route them to your on-premise SAP system through Cloud Connector.

python
# sap_middleware.py β€” PunchConnect β†’ SAP SuccessFactors
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
SAP_BASE = "https://api.successfactors.com"
SAP_COMPANY = "yourCompanyId"
SAP_USER = "api-user"
SAP_PASS = "api-password"
@app.route("/webhook", methods=["POST"])
def handle_punch():
data = request.json
# Create time event in SAP
payload = {
"userId": data["employee_id"],
"eventDate": data["timestamp"][:10],
"eventTime": data["timestamp"][11:19].replace(":", ""),
"eventType": "P10" if data["direction"] == "in" else "P20",
"terminalId": data["device_serial"]
}
response = requests.post(
f"{SAP_BASE}/odata/v2/EmployeeTime",
json=payload,
auth=(f"{SAP_USER}@{SAP_COMPANY}", SAP_PASS),
headers={"Content-Type": "application/json"}
)
if response.status_code in (200, 201):
return jsonify({"status": "ok"}), 200
return jsonify({"error": response.text}), 500

Integration #3: ERPNext (REST API)

ERPNext has a clean REST API that makes this integration straightforward:

> ERPNext tip: Use the Employee Checkin doctype β€” it has built-in auto-attendance that calculates Attendance records from checkins. Don't write to Attendance directly.

javascript
// erpnext_middleware.js β€” PunchConnect β†’ ERPNext
const express = require("express");
const axios = require("axios");
const app = express();
app.use(express.json());
const ERPNEXT_URL = "https://your-site.erpnext.com";
const API_KEY = "your-api-key";
const API_SECRET = "your-api-secret";
const erpnext = axios.create({
baseURL: ERPNEXT_URL,
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
"Content-Type": "application/json",
},
});
app.post("/webhook", async (req, res) => {
const { employee_id, timestamp, direction, event_id } = req.body;
try {
// Find ERPNext employee by attendance device ID
const { data: empList } = await erpnext.get(
`/api/resource/Employee?filters=[["attendance_device_id","=","${employee_id}"]]&fields=["name"]`
);
if (!empList.data.length) {
return res.status(404).json({ error: "employee not found" });
}
const employeeName = empList.data[0].name;
// Create Employee Checkin (ERPNext handles in/out logic)
await erpnext.post("/api/resource/Employee Checkin", {
employee: employeeName,
time: timestamp,
device_id: event_id,
log_type: direction === "in" ? "IN" : "OUT",
});
res.json({ status: "ok" });
} catch (err) {
console.error("ERPNext error:", err.response?.data || err.message);
res.status(500).json({ error: "erp write failed" });
}
});
app.listen(3000);

Integration #4: Custom HRMS (Direct Database)

If you've built your own HRMS, the integration is even simpler. Point PunchConnect's webhook at your attendance endpoint and write directly:

Key detail: The event_id check prevents duplicate records when PunchConnect retries a failed delivery. Always implement idempotency in your webhook handler.

python
# custom_middleware.py β€” PunchConnect β†’ Custom HRMS
from flask import Flask, request, jsonify
import psycopg2
import os
app = Flask(__name__)
conn = psycopg2.connect(os.environ["DATABASE_URL"])
@app.route("/webhook", methods=["POST"])
def handle_punch():
data = request.json
with conn.cursor() as cur:
# Idempotency: skip if event_id already processed
cur.execute(
"SELECT 1 FROM attendance_events WHERE event_id = %s",
(data["event_id"],)
)
if cur.fetchone():
return jsonify({"status": "duplicate"}), 200
cur.execute(
"""INSERT INTO attendance_events
(event_id, employee_id, punch_time, direction, device_serial, method)
VALUES (%s, %s, %s, %s, %s, %s)""",
(data["event_id"], data["employee_id"],
data["timestamp"], data["direction"],
data["device_serial"], data["method"])
)
conn.commit()
return jsonify({"status": "ok"}), 200

Production Checklist

Before going live, verify these five items:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 340" 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">Production Readiness Checklist</text>
<rect x="60" y="55" width="680" height="270" rx="12" stroke="#64748b" stroke-width="1.5" fill="none"/>
<!-- Item 1 -->
<text x="90" y="90" fill="#34d399" font-size="20" font-family="system-ui">βœ…</text>
<text x="120" y="90" fill="#e2e8f0" font-size="15" font-weight="bold" font-family="system-ui">HTTPS endpoint</text>
<text x="120" y="108" fill="#94a3b8" font-size="14" font-family="system-ui">PunchConnect only delivers webhooks to HTTPS URLs</text>
<!-- Item 2 -->
<text x="90" y="140" fill="#34d399" font-size="20" font-family="system-ui">βœ…</text>
<text x="120" y="140" fill="#e2e8f0" font-size="15" font-weight="bold" font-family="system-ui">HMAC signature verification</text>
<text x="120" y="158" fill="#94a3b8" font-size="14" font-family="system-ui">Verify X-PunchConnect-Signature header on every request</text>
<!-- Item 3 -->
<text x="90" y="190" fill="#34d399" font-size="20" font-family="system-ui">βœ…</text>
<text x="120" y="190" fill="#e2e8f0" font-size="15" font-weight="bold" font-family="system-ui">Idempotency via event_id</text>
<text x="120" y="208" fill="#94a3b8" font-size="14" font-family="system-ui">Dedup on event_id to handle retries gracefully</text>
<!-- Item 4 -->
<text x="90" y="240" fill="#34d399" font-size="20" font-family="system-ui">βœ…</text>
<text x="120" y="240" fill="#e2e8f0" font-size="15" font-weight="bold" font-family="system-ui">Employee ID mapping</text>
<text x="120" y="258" fill="#94a3b8" font-size="14" font-family="system-ui">Map device employee_id to ERP employee record before going live</text>
<!-- Item 5 -->
<text x="90" y="290" fill="#34d399" font-size="20" font-family="system-ui">βœ…</text>
<text x="120" y="290" fill="#e2e8f0" font-size="15" font-weight="bold" font-family="system-ui">Error handling + logging</text>
<text x="120" y="308" fill="#94a3b8" font-size="14" font-family="system-ui">Return 500 on failure so PunchConnect retries β€” log everything</text>
</svg>

Employee ID Mapping Strategy

The most common integration headache is matching device employee IDs to ERP records. Three approaches:

1. Badge/barcode field (recommended): Store the device employee ID in your ERP's badge field (Odoo: barcode, ERPNext: attendance_device_id). Clean, no custom code.
2. Lookup table: Maintain a mapping JSON or database table. More flexible but requires maintenance.
3. Shared ID: Use the same employee ID in both systems from day one. Ideal for greenfield deployments.

Handling Edge Cases

Offline devices: When a device goes offline, it buffers punches locally. Once reconnected, PunchConnect delivers the buffered events in order. Your middleware handles them identically β€” the timestamps are from the original punch time, not the delivery time.

Duplicate punches: Employees sometimes punch twice by accident. Your ERP should handle this at the business logic layer β€” for example, ignore a second check-in within 60 seconds of the first.

Timezone mismatches: PunchConnect delivers timestamps in UTC. Convert to your local timezone in the middleware before writing to the ERP. Don't rely on the device's timezone setting.

Missing direction: Some older devices don't report punch direction. PunchConnect marks these as direction: "unknown". Your middleware should infer direction from context β€” if the last punch was "in", the next one is probably "out".

Testing With cURL

Before building middleware, test the flow manually:

bash
# Register your webhook URL in PunchConnect
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/webhook",
"events": ["attendance.punch"]
}'

Deployment Options

| Platform | Cost | Best For |
|----------|------|----------|
| Railway | ~$5/mo | Quick deployments, auto-scaling |
| Render | Free tier available | Startups, low volume |
| AWS Lambda | Pay-per-request | High volume, cost optimization |
| VPS (Hetzner) | €4/mo | Full control, on-premise adjacent |
| Docker on-premise | Your hardware | Data sovereignty requirements |

For most customers, a $5/month Railway deployment handles thousands of punches per day without breaking a sweat.

FAQ

How fast do punches arrive in my ERP?

Sub-second from the moment PunchConnect receives the event. Total latency from finger-on-scanner to ERP record is typically under 3 seconds β€” most of that is network transit.

What happens if my middleware is down?

PunchConnect retries with exponential backoff for up to 72 hours. When your middleware comes back online, it receives all buffered events in order. No data is lost.

Do I need a static IP for the webhook endpoint?

No. PunchConnect delivers webhooks to any HTTPS URL β€” serverless functions, cloud platforms, or tunnels like ngrok for development. See our guide on biometric attendance without a static IP.

Can I integrate with multiple ERPs simultaneously?

Yes. Register multiple webhook URLs in PunchConnect and each will receive every event independently. Useful for companies migrating between systems.

How do I handle 50+ devices across multiple sites?

The middleware doesn't care how many devices you have β€” every punch follows the same webhook β†’ middleware β†’ ERP path. PunchConnect handles the device management layer. See our guide on managing devices across multiple sites.

---

Ready to connect your biometric devices to your ERP? PunchConnect gives you a REST API and webhooks out of the box. Start your free 7-day trial β€” no credit card required. Most teams have their first integration running within an hour.

*Already using PunchConnect? Check out our webhook setup guide for advanced configuration, or see how AgriWise integrated 24,000+ employees across 50+ sites.*

Related articles

Real-Time Attendance in Your ERP: Webhooks, Middleware, and 20 Lines of Code | PunchConnect