API & webhooks
Verifying webhook signatures
The exact HMAC scheme, working verifiers in Node and Python, and the delivery guarantees you should design around.
What you receive
Webhooks are a Team feature. Register an HTTPS endpoint in the dashboard, choose your events, and FieldScan POSTs JSON to it as scans happen. There are exactly two events:
scan.created— a barcode was added for the first timescan.updated— an existing barcode was scanned again
Each delivery carries six headers that matter:
Content-Type: application/json
X-FieldScan-Event: scan.created
X-FieldScan-Timestamp: 1785398400
X-FieldScan-Signature: sha256=6a2f...
X-FieldScan-Delivery: 0192b1c4-...
X-FieldScan-Attempt: 1
X-FieldScan-Delivery is stable across retries of the same delivery, and
X-FieldScan-Attempt counts them — together they are your dedupe key.
The body is event, timestamp and a data object with the scan. One precision worth
knowing: a webhook fired by a phone scan includes location_name in data; one fired by
an API write does not — API writes carry no location.
The scheme, exactly
The signature is HMAC-SHA256. The key is your endpoint’s secret (48 hex characters, generated when you create the endpoint). The signed message is the timestamp, a literal dot, then the raw request body:
signature = "sha256=" + lowercase_hex( HMAC_SHA256( secret, timestamp + "." + raw_body ) )
The sha256= prefix is part of the header value. Compare with a constant-time function.
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyFieldScan(rawBody, timestamp, signatureHeader, secret) {
const expected =
'sha256=' +
createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}
// Express: mount the raw parser on the webhook route so req.body stays a Buffer.
// app.post('/hooks/fieldscan', express.raw({ type: 'application/json' }), (req, res) => {
// const ok = verifyFieldScan(
// req.body.toString('utf8'),
// req.get('X-FieldScan-Timestamp'),
// req.get('X-FieldScan-Signature'),
// process.env.FIELDSCAN_WEBHOOK_SECRET,
// );
// if (!ok) return res.sendStatus(401);
// res.sendStatus(200);
// });
Python
import hashlib
import hmac
def verify_fieldscan(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
message = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
Reject anything that fails, and consider rejecting timestamps older than a few minutes — the timestamp is in the signed message precisely so a captured delivery cannot be replayed later without detection.
Delivery guarantees, honestly
Delivered at least once
Events are queued in the database and retried with backoff until they land or the attempt cap is reached, then kept as a readable record. At least once means occasionally twice: dedupe on X-FieldScan-Delivery.
Endpoints are independent
Deliveries to multiple endpoints run separately — one failing does not block the others.
Public HTTPS only
Plain HTTP is rejected, and private or internal addresses are silently skipped.
Emission happens in the database itself, so every path that writes a scan fires the
event — a phone flushing its offline queue, a bulk import, an API write. Expect a delivery
within about a minute of the write, acknowledge fast (write to a queue, return 200,
process later), and reconcile on a schedule with
GET /scans?since=... if your use case cannot tolerate
waiting out a retry. The webhook is the low-latency path; the API is the source of truth.