When any machine normalizes, push its canonical readings straight into SiteWise. Forge handles the tag translation; this template handles the transport.
Sload → spindle load, per OEM, per model./v1/normalize.Forge tools authenticate with none, bearer, basic, or hmac. AWS data-plane APIs require SigV4 request signing, which is not a static header, so Forge cannot call BatchPutAssetPropertyValue directly. The standard pattern, and the one below, is a thin API Gateway + Lambda shim that signs the call for you.
Machine tags Forge kernel Your AWS account
───────────── ───────────────── ────────────────────────
Sload: 72.5 ──► /v1/normalize
CoolantTemp: 85 ──► canonical fields ──► API Gateway (bearer token)
PartCount: 143 ──► trigger fires └─► Lambda (signs SigV4)
└─► SiteWise BatchPutAssetPropertyValue
fired_at into SiteWise's epoch timeInSeconds, and casts numeric readings to JSON numbers. Both are required by the SiteWise API and neither is something a webhook body template can express.
Forge's canonical names are stable across OEMs, so they make good SiteWise property aliases. Create the alias once per property and you never look up a propertyId again. These are real canonical names emitted by /v1/normalize.
| Forge canonical field | SiteWise property alias | Value type | Unit |
|---|---|---|---|
spindle_load_pct | /forge/{machine_id}/spindle_load_pct | doubleValue | % |
spindle_speed_rpm | /forge/{machine_id}/spindle_speed_rpm | doubleValue | RPM |
sensor_readings.coolant_temp | /forge/{machine_id}/coolant_temp | doubleValue | °C |
sensor_readings.vibration | /forge/{machine_id}/vibration | doubleValue | mm/s |
sensor_readings.bearing_temp | /forge/{machine_id}/bearing_temp | doubleValue | °C |
parts_count | /forge/{machine_id}/parts_count | integerValue | count |
program_execution_state | /forge/{machine_id}/execution_state | stringValue | enum |
alarm_code | /forge/{machine_id}/alarm_code | stringValue | enum |
operating_hours | /forge/{machine_id}/operating_hours | doubleValue | h |
energy_kwh | /forge/{machine_id}/energy_kwh | doubleValue | kWh |
axes.x_load_pct | /forge/{machine_id}/axes/x_load_pct | doubleValue | % |
tool_life_pct | /forge/{machine_id}/tool_life_pct | doubleValue | % |
sensor_readings.coolant_temp arrive as flat keys, so {{sensor_readings.coolant_temp}} resolves correctly in a payload template. The dot is part of the name, not a path traversal.
One Lambda behind an API Gateway HTTP API. It takes Forge's canonical payload and writes it to SiteWise. Paste this as the function body (Python 3.12, needs iotsitewise:BatchPutAssetPropertyValue in its execution role).
import boto3, json, os, time, uuid
from datetime import datetime, timezone
sw = boto3.client("iotsitewise")
SHARED_TOKEN = os.environ["FORGE_SHARED_TOKEN"]
ALIAS_PREFIX = os.environ.get("ALIAS_PREFIX", "/forge")
# canonical field -> (alias suffix, SiteWise value key)
MAP = {
"spindle_load_pct": ("spindle_load_pct", "doubleValue"),
"spindle_speed_rpm": ("spindle_speed_rpm", "doubleValue"),
"sensor_readings.coolant_temp": ("coolant_temp", "doubleValue"),
"sensor_readings.vibration": ("vibration", "doubleValue"),
"sensor_readings.bearing_temp": ("bearing_temp", "doubleValue"),
"parts_count": ("parts_count", "integerValue"),
"program_execution_state": ("execution_state", "stringValue"),
"alarm_code": ("alarm_code", "stringValue"),
"operating_hours": ("operating_hours", "doubleValue"),
"energy_kwh": ("energy_kwh", "doubleValue"),
"tool_life_pct": ("tool_life_pct", "doubleValue"),
}
def _epoch(iso):
if not iso:
return int(time.time())
try:
return int(datetime.fromisoformat(str(iso).replace("Z", "+00:00")).timestamp())
except ValueError:
return int(time.time())
def _cast(kind, raw):
# Forge interpolates template values as strings; SiteWise needs real types.
if kind == "doubleValue":
return {"doubleValue": float(raw)}
if kind == "integerValue":
return {"integerValue": int(float(raw))}
return {"stringValue": str(raw)}
def handler(event, _ctx):
if event.get("headers", {}).get("authorization") != f"Bearer {SHARED_TOKEN}":
return {"statusCode": 401, "body": "unauthorized"}
body = json.loads(event.get("body") or "{}")
machine_id = body.get("machine_id") or "unknown"
ts = _epoch(body.get("fired_at"))
readings = body.get("readings") or {}
entries = []
for field, raw in readings.items():
if field not in MAP or raw is None or str(raw).startswith("{{"):
continue # unmapped, or the field wasn't in this event
suffix, kind = MAP[field]
try:
value = _cast(kind, raw)
except (TypeError, ValueError):
continue # non-numeric reading on a numeric property
entries.append({
"entryId": uuid.uuid4().hex[:32],
"propertyAlias": f"{ALIAS_PREFIX}/{machine_id}/{suffix}",
"propertyValues": [{
"value": value,
"timestamp": {"timeInSeconds": ts, "offsetInNanos": 0},
"quality": "GOOD",
}],
})
if not entries:
return {"statusCode": 204, "body": ""}
written, errors = 0, []
for i in range(0, len(entries), 10): # SiteWise caps at 10 entries per call
resp = sw.batch_put_asset_property_value(entries=entries[i:i + 10])
errors.extend(resp.get("errorEntries") or [])
written += len(entries[i:i + 10])
return {"statusCode": 200,
"body": json.dumps({"written": written - len(errors), "errors": errors})}
FORGE_SHARED_TOKEN to a random secret; you will hand the same value to Forge in step 2.
The tool holds the destination, the auth, and the body shape. auth_secret is encrypted at rest and never readable back.
curl -sS -X POST https://forge.foundrynet.io/v1/tools \
-H "Authorization: Bearer $FORGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "AWS IoT SiteWise",
"description": "Push canonical readings to SiteWise via the ingest shim",
"url": "https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/ingest",
"method": "POST",
"auth_type": "bearer",
"auth_secret": "YOUR_FORGE_SHARED_TOKEN",
"payload_template": {
"machine_id": "{{machine_id}}",
"fired_at": "{{timestamp}}",
"readings": {
"spindle_load_pct": "{{spindle_load_pct}}",
"spindle_speed_rpm": "{{spindle_speed_rpm}}",
"sensor_readings.coolant_temp": "{{sensor_readings.coolant_temp}}",
"sensor_readings.vibration": "{{sensor_readings.vibration}}",
"parts_count": "{{parts_count}}",
"program_execution_state": "{{program_execution_state}}",
"operating_hours": "{{operating_hours}}",
"energy_kwh": "{{energy_kwh}}"
}
}
}'
The response carries the id. Keep it for the next step.
{{field}} the machine did not report in that event passes through as the literal string {{field}}. That is deliberate, so a missing field is visible in delivery logs rather than silently blank. The shim above skips those entries.
In plain terms: on every normalize event for this machine, POST the canonical readings to SiteWise. A trigger needs a condition, so use one that is always true during production, such as the spindle turning at all.
curl -sS -X POST https://forge.foundrynet.io/v1/triggers \
-H "Authorization: Bearer $FORGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Stream canonical data to SiteWise",
"machine_id": "YOUR_MACHINE_ID",
"condition": { "field": "spindle_speed_rpm", "op": "gte", "value": 0 },
"actions": [ { "tool_id": "TOOL_ID_FROM_STEP_2" } ],
"enabled": true
}'
spindle_speed_rpm > 0 to skip idle time) or throttle upstream if you are cost-sensitive.
Send one normalize call with raw OEM tag names and confirm it lands.
curl -sS -X POST https://forge.foundrynet.io/v1/normalize \
-H "Content-Type: application/json" \
-d '{"oem":"Haas","model":"VF-2","machine_id":"YOUR_MACHINE_ID",
"data":{"Sload":72.5,"Sspeed":8200,"CoolantTemp":85.2,"PartCount":143}}'
Forge returns the canonical translation:
{"normalized":{"spindle_load_pct":72.5,
"spindle_speed_rpm":8200,
"sensor_readings.coolant_temp":85.2,
"parts_count":143}, ...}
Then check delivery, which records status code and response for every dispatch:
curl -sS https://forge.foundrynet.io/v1/triggers/TRIGGER_ID/executions \ -H "Authorization: Bearer $FORGE_API_KEY"
The exact body produced by the template in step 2, after interpolation.
POST /ingest
Authorization: Bearer YOUR_FORGE_SHARED_TOKEN
Content-Type: application/json
X-FoundryNet-Trigger-Id: 4f2c...
X-FoundryNet-Tool-Id: 9a71...
{
"machine_id": "mint_7f3a91c4",
"fired_at": "2026-07-28T18:04:11.882714+00:00",
"readings": {
"spindle_load_pct": "72.5",
"spindle_speed_rpm": "8200",
"sensor_readings.coolant_temp": "85.2",
"parts_count": "143",
"program_execution_state": "ACTIVE",
"operating_hours": "{{operating_hours}}",
"energy_kwh": "{{energy_kwh}}"
}
}
And what the shim writes to SiteWise:
{
"entries": [{
"entryId": "8c1d4e0f7b2a4a5c9e3f1d6b8a0c2e4f",
"propertyAlias": "/forge/mint_7f3a91c4/spindle_load_pct",
"propertyValues": [{
"value": { "doubleValue": 72.5 },
"timestamp": { "timeInSeconds": 1785261851, "offsetInNanos": 0 },
"quality": "GOOD"
}]
}]
}