Skip to content

API & webhooks

Verifying webhook signatures

The exact HMAC scheme, working verifiers in Node and Python, and the delivery guarantees you should design around.

← All guides7 minUpdated Aug 4, 2026

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 time
  • scan.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.