Validating Webhook Requests

We recommend validating the signature on all incoming webhook requests to protect against malicious actors sending requests to your webhook endpoint.

Each webhook request will be signed by Streem's backend, and the signature included as an HTTP header along with the request. The signature uses the HMAC-SHA-256 algorithm with a customer-provided signing key.

Webhook configuration

  • Each Webhook can be configured by the customer with one or more signing keys (see "Webhook Signing Keys" below).
  • Each Webhook.Header has a bool include_in_request_signature field that indicates whether that header should be included in the signature computation.

Webhook signing keys

TODO: describe how to manage webhook signing keys

Webhook request contents

The webhook HTTP request uses the following HTTP headers:

  • Streem-Signature-Headers
    • Contains the names of the headers used to compute the signature.
    • The format of this field is a colon-separated list with no spaces (e.g. Header1:Header2:Header3).
  • Streem-Signature
    • Contains the computed signature. If the webhook is configured with multiple signing keys (e.g. during key rotation), then the request will contain a comma-separated list of signatures (one for each key).
  • Streem-Sent-At
    • Contains the same value as in the sent_at field of the webhook request (see example below). This is included as a header so that it is possible to validate the timestamp of the signed request without decoding the request body.
    • The format of this field is an RFC 3339 timestamp in the UTC timezone (e.g. 1972-01-01T10:00:20.021Z).

Validating the signature

Signature details

The webhook request signature is computed over:

  • The timestamp of the request, as provided in the Streem-Sent-At header
  • Any custom headers defined for that webhook (sorted alphabetically) that have the Webhook.Header.include_in_request_signature field set to true
  • The HTTP request body
    • If the request is using GET instead of POST, then the signature should be computed using the request body before it is URL-encoded into the body query parameter.

Validation algorithm

After receiving the webhook request message, the recipient should validate the HMAC signature by attempting to re-create the signature by hashing the raw message body with the signing keys.

To validate the request:

  1. Check that the timestamp in the Streem-Sent-At header is within a reasonable period of time (e.g. not more than 5 minutes in the past or 5 minutes in the future).
  2. Ensure that the Streem-Signature-Headers header contains the custom headers expected by the recipient.
  3. Re-create the signature:
    1. Extract the name and value of each header listed in Streem-Signature-Headers from the HTTP request. Make sure to preserve the order of headers as listed in Streem-Signature-Headers, as well as the capitalization of the header names, when computing the signature below.
    2. Extract the text of the HTTP request body as an array of bytes.
      • If the request was made using POST: the entire body of the POST request is used, including line endings.
      • If the request was made using GET: the value of the body query parameter is used. The value should be URL-decoded and encoded to a byte array using the UTF-8 encoding.
    3. Combine the headers and body into a single array of bytes. Each header name and value should be separated with an = character, and the headers and body should be separated from each other using a ; character. For example: Header1=value1;Header2=value2;body text.
    4. Compute a SHA-256 HMAC digest for the array of bytes using the signing key, and encode the digest using the base64url encoding (see https://www.rfc-editor.org/rfc/rfc4648#section-5).
  4. Compare the base64url digest from the previous step to the values of the Streem-Signature header. There will be one value in the Streem-Signature header for each signing key configured for your account. At least one of the header values must exactly match the computed digest. If there is no match, then the webhook request may be compromised and it should not be trusted. Only one match is required.

The code example below demonstrates the process of implementing this message authenticity check.

Python pseudo-code for validating the signature

def validate_webhook_request(
		# HTTP request object
		req,
		# the shared signing key
		signing_secret: str,
):
		sent_at = req.headers['Streem-Sent-At'][0]
		
		# make sure the request is current
		req_time_skew = datetime.now() - datetime.fromisoformat(sent_at)
		allowed_skew = timedelta(seconds=5) # adjust timedelta as desired
		if req_time_skew > allowed_skew:
				raise RuntimeError('Request timestamp is too far in the past')
		elif req_time_skew < -allowed_skew:
				raise RuntimeError('Request timestamp is in the future')

		# make sure the custom headers that were defined for the webhook
    # are covered by the signature
		header_names = req.headers['Streem-Signature-Headers'][0].split(':')
		if 'My-Custom-Header' not in header_names:
				raise RuntimeError("Request signature does not cover the 'My-Custom-Header' header")

		# calculate the signature input
    input_parts = []
		for header_name in header_names:
		    input_parts.append(f"{header_name}={req.headers[header_name][0]}")
		input_msg = ';'.join(input_parts).encode('utf-8')	+ b';' + req.body
		
		# calculate the signature
		expected_signature = hmac.new(
				digest='sha256',
				key=signing_secret.encode('utf-8'),
				msg=input_msg,
		).hexdigest()
		
		# validate that one of the signatures in the request matches the expected signature
		signatures = [sig.strip() for sig in req.headers['Streem-Signature'][0].split(',')]
		if not any(hmac.compare_digest(expected_signature, s) for s in signature):
				raise RuntimeError('Request signature is invalid')

Example

Given a webhook configured with:

  • Signing key shared secret: s3kr3t
  • Custom headers:
    • ExampleCom-ClientId: abcde12345

When the WebhookRequestBody is:

{
    "request_sid": "wh_4WUzV0k2jOrSWwytOjT13e:wev_5kjwaB2gr8dHU9coezxMqR:1",
    "webhook_sid": "wh_4WUzV0k2jOrSWwytOjT13e",
    "company_sid": "co_2syD0fVA3Cr6H9IKIrYNID",
    "sent_at": "2022-11-25T17:50:32.114703Z",
    "event": {
        "event_sid": "wev_5kjwaB2gr8dHU9coezxMqR",
        "event_type": "group_reservation_created",
        "occurred_at": "2022-11-25T17:50:31.849273Z",
        "payload": {
            "@type": "type.streem.com/streem.api.events.GroupReservationCreated",
            "group_reservation": {
                "company_sid": "co_2syD0fVA3Cr6H9IKIrYNID",
                "reservation_sid": "rsv_98FNaZqZUf7ul6u00oUUWA",
                "external_user_id": "96cabbba-ee34-4460-aabb-b49c1ef8106b",
                "reservation_status": "GROUP_RESERVATION_STATUS_QUEUED",
                "queue_position": 7,
                "group": {
                    "sid": "grp_26Wat5GXaPkmDd8eK6NgZQ",
                    "name": "AGENT"
                }
            }
        }
    }
}

Then the HTTP request to the webhook will be:

TODO: update the Streem-Signature value to be correct for the example request headers

POST /some/webhook/url HTTP/1.1
Host: some.webhook.host
Streem-Signature-Headers: Streem-Sent-At:ExampleCom-ClientId
Streem-Signature: 6fbe2aee586b0d5d0d0fcf2e54e82e2e49f45e5b7a3aefdb5497a914082f3714
Streem-Sent-At: 2022-11-25T17:50:32.114703Z
ExampleCom-ClientId: abcde12345
Content-Type: application/json

{
    "request_sid": "wh_4WUzV0k2jOrSWwytOjT13e:wev_5kjwaB2gr8dHU9coezxMqR:1",
    "webhook_sid": "wh_4WUzV0k2jOrSWwytOjT13e",
    "company_sid": "co_2syD0fVA3Cr6H9IKIrYNID",
    "sent_at": "2022-11-25T17:50:32.114703Z",
    "event": {
        "event_sid": "wev_5kjwaB2gr8dHU9coezxMqR",
        "event_type": "group_reservation_created",
        "occurred_at": "2022-11-25T17:50:31.849273Z",
        "payload": {
            "@type": "type.streem.com/streem.api.events.GroupReservationCreated",
            "group_reservation": {
                "company_sid": "co_2syD0fVA3Cr6H9IKIrYNID",
                "reservation_sid": "rsv_98FNaZqZUf7ul6u00oUUWA",
                "external_user_id": "96cabbba-ee34-4460-aabb-b49c1ef8106b",
                "reservation_status": "GROUP_RESERVATION_STATUS_QUEUED",
                "queue_position": 7,
                "group": {
                    "sid": "grp_26Wat5GXaPkmDd8eK6NgZQ",
                    "name": "AGENT"
                }
            }
        }
    }
}