Overview

Terra will include a header - terra-signature - to all requests POSTed to your webhook URL, and to all responses returned from the service. The header will contain a hash value (hexidecimal encoded) unique to the request and to your developer ID.

This allows you to verify that the events were sent by Terra and not by a third party.

The signature secret (Terra Secret) is provided to you along with your developer ID on the Terra Dashboard and can be reset at any time.

When building your own hash to verify the signature, make sure to ensure that you create a hexidecimal hash digest, other encodings such as base64 will produce erroneous results.

Step 1 - Parse the Header

The terra-signature header will be in the following format:

t=<timestamp>,v1=<hash>

To extract each value, you can split the header string on the , (comma) character and then split the created strings on = (equals sign) and take the second part which will contain the values from the header.

Step 2 - Prepare Message String

The string to be hashed (message string) is created by concatenating the timestamp and the raw request payload with the . (full stop) character.

❗️

Watch Out!

You must ensure that you use the raw request payload before it has been parsed from JSON format otherwise when you dump the parsed object to a string it may not be in the correct format.

Step 3 - Compute Expected Signature

You should then compute a HMAC using the message string created previously and the SHA-256 hashing algorithm. You should use your signing secret as the key and the message string as the message.

Step 4 - Compare the Signatures

Compare the signature in the header to the expected signature. For an equality match, compute the difference between the current timestamp and the received timestamp, then decide if the difference is within your tolerance.

Note that due to Terra performing webhook retries if your server does not accept a payload, the difference between the current timestamp and the timestamp specified in the request header may be very large - you should determine whether or not this matters to you.

Examples

If you would like to see an example added for a specific language/framework please contact us at [email protected].

import hashlib
import hmac

import flask
from flask import request

SECRET = "YOUR_TERRA_SECRET".encode("utf-8")


@app.route("/consumeWebhook", methods=["POST"])
def consume_terra_webhook():
  body = request.get_data().decode('utf-8')
  header = request.headers["terra-signature"]
  
    t, sig = (pair.split("=")[-1] for pair in signature_header.split(","))
  
  computed_signature = hmac.new(
      SECRET, 
      msg=f"{t}.{body}".encode("utf-8"),
      digestmod=hashlib.sha256
  ).hexdigest() 

  if signature != check_signature:
    return flask.Response(status=401)
  # Signature was validated
  return flask.Response(status=200)
@RestController
public class TerraWebhookController {
  private static final HexFormat HEX = HexFormat.of();
  private static final String SECRET = "YOUR_SECRET_KEY";
  
  @PostMapping("/consumeTerraWebhook")
  public ResponseEntity<?> consumeTerraWebhook(
    @RequestBody String payload, @RequestHeader("terra-signature") String terraSignature
  ) {
    String[] parts = terraSignature.split(",");
    String timestamp = parts[0].substring(2);
    String signature = parts[1].substring(3);
    
    SecretKeySpec secret = new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    
    Mac digest;
    try {
      digest = Mac.getInstance("HmacSHA256");
      digest.init(secret);
    } catch (NoSuchAlgorithmException | InvalidKeyException e) {
      return new TerraWebhookResponse("error");
    }
    
    digest.update(timestamp.getBytes(StandardCharsets.UTF_8));  
    digest.update(".".getBytes(StandardCharsets.UTF_8));
    digest.update(payload.getBytes(StandardCharsets.UTF_8));

    String encodedSignature = HEX.formatHex(digest.doFinal());
    if (!signature.equals(encodedSignature)) {   
        return ResponseEntity.status(401).build();     
    }       
    return ResponseEntity.ok().build();   
    }
 }
const crypto = require("crypto");
// terraSignature from headers
// secret from dashboard
// payload is the req.body raw string
function verifySignature(terraSignature, payload, secret) {
    const s = terraSignature.split(",");
    const t = s[0].split("=")[1];
    const v1 = s[1].split("=")[1];
    var hmac = crypto
        .createHmac("sha256", secret)
        .update(t + "." + payload)
        .digest("hex");
    return hmac === v1;
}

const payload = '{"user": {"provider": "TEMPO", "last_webhook_update": null, "user_id": "6dca2b70-c028-42f1-bf18-14474607340c"}, "type": "activity", "data": [{"power_data": {"max_watts": 30.15489683644471, "avg_watts": 25.029001299966765, "power_samples": [{"watts": 31.570639669854252, "timestamp": "2022-03-21T07:45:47.404021+00:00", "timer_duration_seconds": 0}, {"watts": 49.521872663270564, "timestamp": "2022-03-21T07:56:47.404021+00:00", "timer_duration_seconds": 11}, {"watts": 59.91660943401078, "timestamp": "2022-03-21T08:07:47.404021+00:00", "timer_duration_seconds": 22}, {"watts": 15.74394429544796, "timestamp": "2022-03-21T08:18:47.404021+00:00", "timer_duration_seconds": 33}, {"watts": 40.129059383698134, "timestamp": "2022-03-21T08:29:47.404021+00:00", "timer_duration_seconds": 44}]}, "device_data": {"name": null, "serial_number": null, "activation_timestamp": null, "manufacturer": null, "software_version": null, "hardware_version": null, "other_devices": []}, "polyline_map_data": {"summary_polyline": null}, "energy_data": {"energy_kilojoules": null, "energy_planned_kilojoules": null}, "active_durations_data": {"activity_levels_samples": [{"activity_level": 3, "timestamp": "2022-03-21T07:45:47.404021+00:00"}, {"activity_level": 1, "timestamp": "2022-03-21T07:56:47.404021+00:00"}, {"activity_level": 5, "timestamp": "2022-03-21T08:07:47.404021+00:00"}, {"activity_level": 4, "timestamp": "2022-03-21T08:18:47.404021+00:00"}, {"activity_level": 3, "timestamp": "2022-03-21T08:29:47.404021+00:00"}], "vigorous_intensity_seconds": 2930.5394136854634, "inactivity_seconds": 2082.3340560721526, "low_intensity_seconds": 343.34227067114347, "num_continuous_inactive_periods": null, "moderate_intensity_seconds": 3113.324410786543, "rest_seconds": 2543.9016848574306, "activity_seconds": 1135.896454860647}, "TSS_data": {"samples": []}, "movement_data": {"max_pace_minutes_per_kilometre": null, "normalized_speed_metres_per_second": 2.9139556399885844, "cadence_samples": [], "avg_speed_metres_per_second": 12.683611338909904, "speed_samples": [], "avg_cadence": 52.20731627221516, "max_torque_newton_metres": 10.351917948413472, "max_cadence": 57.448571339065126, "max_speed_metres_per_second": 1.7888892058920929, "avg_pace_minutes_per_kilometre": null, "max_velocity_metres_per_second": 5.302880736621802, "avg_velocity_metres_per_second": 10.500997820656906, "avg_torque_newton_metres": 9.074087870551914}, "calories_data": {"net_intake_calories": 199.9825804951148, "net_activity_calories": 552.4189320529961, "total_burned_calories": 979.0905476197471, "BMR_calories": 1085.7307147510805}, "distance_data": {"detailed": {"distance_samples": [{"distance_metres": 0, "timestamp": "2022-03-21T07:45:47.404021+00:00", "timer_duration_seconds": 0}, {"distance_metres": 48.44061070556159, "timestamp": "2022-03-21T07:56:47.404021+00:00", "timer_duration_seconds": 11}, {"distance_metres": 78.2259374754136, "timestamp": "2022-03-21T08:07:47.404021+00:00", "timer_duration_seconds": 22}, {"distance_metres": 119.51155863861513, "timestamp": "2022-03-21T08:18:47.404021+00:00", "timer_duration_seconds": 33}, {"distance_metres": 144.03462492501959, "timestamp": "2022-03-21T08:29:47.404021+00:00", "timer_duration_seconds": 44}], "elevation_samples": []}, "summary": {"elevation": {"gain_planned_metres": null, "loss_actual_metres": null, "max_metres": 51.199192831461566, "min_metres": 29.67829060263131, "avg_metres": null, "gain_actual_metres": null}, "floors_climbed": null, "swimming": {"num_laps": 17, "pool_length_metres": null, "num_strokes": 252}, "steps": 12568, "distance_metres": 157.564708414663}}, "heart_rate_data": {"detailed": {"hrv_samples": [{"timestamp": "2022-03-21T07:45:47.404021+00:00", "hrv": 58}, {"timestamp": "2022-03-21T07:56:47.404021+00:00", "hrv": 66}, {"timestamp": "2022-03-21T08:07:47.404021+00:00", "hrv": 77}, {"timestamp": "2022-03-21T08:18:47.404021+00:00", "hrv": 43}, {"timestamp": "2022-03-21T08:29:47.404021+00:00", "hrv": 47}], "hr_samples": [{"bpm": 83, "timestamp": "2022-03-21T07:45:47.404021+00:00"}, {"bpm": 90, "timestamp": "2022-03-21T07:56:47.404021+00:00"}, {"bpm": 112, "timestamp": "2022-03-21T08:07:47.404021+00:00"}, {"bpm": 163, "timestamp": "2022-03-21T08:18:47.404021+00:00"}, {"bpm": 118, "timestamp": "2022-03-21T08:29:47.404021+00:00"}]}, "summary": {"max_hr": 144.89178910773677, "user_hr_max": 189, "resting_hr": 50.08058645758455, "min_hr": 67.07892442446678, "avg_hr_variability": 91.05290331260525, "avg_hr": 111.37120129241694}}, "metadata": {"name": null, "type": 8, "start_time": "2022-03-21T07:45:47.404021+00:00", "city": null, "state": null, "summary_id": "204421706369", "end_time": "2022-03-21T08:37:47.404021+00:00", "country": null, "upload_type": 0}, "oxygen_data": {"saturation_percentage": 63.94556590561501, "saturation_samples": [], "vo2_samples": [], "max_volume_ml_per_min_per_kg_day": null}, "work_data": {"work_in_kilojoules": null}, "position_data": {"start_pos_lat_lng": [52.61831913642271, 0.46745174277930757], "end_pos_lat_lng": [52.18417888719253, 1.39516967016118], "centre_pos_lat_lng": [], "position_samples": []}, "lap_data": {"laps": []}, "strain_data": {"strain_level": 5.487215959367661}, "MET_data": {"samples": [{"timestamp": "2022-03-21T07:45:47.404021+00:00", "level": 8.417435109789558}, {"timestamp": "2022-03-21T07:56:47.404021+00:00", "level": 4.471069974577138}, {"timestamp": "2022-03-21T08:07:47.404021+00:00", "level": 5.417952686395167}, {"timestamp": "2022-03-21T08:18:47.404021+00:00", "level": 7.050821963487477}, {"timestamp": "2022-03-21T08:29:47.404021+00:00", "level": 13.042276135672802}], "num_moderate_intensity_minutes": 16.163975388739132, "num_low_intensity_minutes": 4.7087465216761055, "num_high_intensity_minutes": 23.433119468097434, "num_inactive_minutes": 17.014989028308108, "avg_level": 13.111347112097553}}]}'
const terraSignature = "t=1647859187,v1=0620ec14ff0aa058f9fdc1f11df17d40ea5a4583c93986ec71c6e8c7c9fb00cb"
const secret = "fa7f9a24c0f83a2266eb67d4c550bfe2045a4878d5fe6247"

console.log(verifySignature(terraSignature, payload, secret))
// prints out "true"