diff --git a/README.md b/README.md
index 7e914841..0ffb4335 100644
--- a/README.md
+++ b/README.md
@@ -141,6 +141,29 @@ client.hris.directory.list_individuals(
)
```
+## Webhook Verification
+
+We provide helper methods for verifying that a webhook request came from Finch, and not a malicious third party.
+
+You can use `finch.webhooks.verify_signature(body: string, headers, secret?) -> None` or `finch.webhooks.unwrap(body: string, headers, secret?) -> Payload`,
+both of which will raise an error if the signature is invalid.
+
+Note that the "body" parameter must be the raw JSON string sent from the server (do not parse it first).
+The `.unwrap()` method can parse this JSON for you into a `Payload` object.
+
+For example, in [FastAPI](https://fastapi.tiangolo.com/):
+
+```py
+@app.post('/my-webhook-handler')
+async def handler(request: Request):
+ body = await request.body()
+ secret = os.environ['FINCH_WEBHOOK_SECRET'] # env var used by default; explicit here.
+ payload = client.webhooks.unwrap(body, request.headers, secret)
+ print(payload)
+
+ return {'ok': True}
+```
+
## Handling errors
When the library is unable to connect to the API (e.g., due to network connection problems or a timeout), a subclass of `finch.APIConnectionError` is raised.
diff --git a/api.md b/api.md
index 4205ef26..9c37bfe7 100644
--- a/api.md
+++ b/api.md
@@ -223,3 +223,10 @@ Methods:
- client.account.disconnect() -> DisconnectResponse
- client.account.introspect() -> Introspection
+
+# Webhooks
+
+Methods:
+
+- client.webhooks.unwrap(\*args) -> object
+- client.webhooks.verify_signature(\*args) -> None
diff --git a/src/finch/_client.py b/src/finch/_client.py
index 1efed683..2399240c 100644
--- a/src/finch/_client.py
+++ b/src/finch/_client.py
@@ -49,17 +49,20 @@ class Finch(SyncAPIClient):
ats: resources.ATS
providers: resources.Providers
account: resources.Account
+ webhooks: resources.Webhooks
# client options
access_token: str | None
client_id: str | None
client_secret: str | None
+ webhook_secret: str | None
def __init__(
self,
*,
client_id: str | None = None,
client_secret: str | None = None,
+ webhook_secret: str | None = None,
base_url: Optional[str] = None,
access_token: Optional[str] = None,
timeout: Union[float, Timeout, None] = DEFAULT_TIMEOUT,
@@ -87,6 +90,7 @@ def __init__(
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
- `client_id` from `FINCH_CLIENT_ID`
- `client_secret` from `FINCH_CLIENT_SECRET`
+ - `webhook_secret` from `FINCH_WEBHOOK_SECRET`
"""
self.access_token = access_token
@@ -96,6 +100,9 @@ def __init__(
client_secret_envvar = os.environ.get("FINCH_CLIENT_SECRET", None)
self.client_secret = client_secret or client_secret_envvar or None
+ webhook_secret_envvar = os.environ.get("FINCH_WEBHOOK_SECRET", None)
+ self.webhook_secret = webhook_secret or webhook_secret_envvar or None
+
if base_url is None:
base_url = f"https://api.tryfinch.com"
@@ -116,6 +123,7 @@ def __init__(
self.ats = resources.ATS(self)
self.providers = resources.Providers(self)
self.account = resources.Account(self)
+ self.webhooks = resources.Webhooks(self)
@property
def qs(self) -> Querystring:
@@ -151,6 +159,7 @@ def copy(
*,
client_id: str | None = None,
client_secret: str | None = None,
+ webhook_secret: str | None = None,
access_token: str | None = None,
base_url: str | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
@@ -189,6 +198,7 @@ def copy(
return self.__class__(
client_id=client_id or self.client_id,
client_secret=client_secret or self.client_secret,
+ webhook_secret=webhook_secret or self.webhook_secret,
base_url=base_url or str(self.base_url),
access_token=access_token or self.access_token,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
@@ -272,17 +282,20 @@ class AsyncFinch(AsyncAPIClient):
ats: resources.AsyncATS
providers: resources.AsyncProviders
account: resources.AsyncAccount
+ webhooks: resources.AsyncWebhooks
# client options
access_token: str | None
client_id: str | None
client_secret: str | None
+ webhook_secret: str | None
def __init__(
self,
*,
client_id: str | None = None,
client_secret: str | None = None,
+ webhook_secret: str | None = None,
base_url: Optional[str] = None,
access_token: Optional[str] = None,
timeout: Union[float, Timeout, None] = DEFAULT_TIMEOUT,
@@ -310,6 +323,7 @@ def __init__(
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
- `client_id` from `FINCH_CLIENT_ID`
- `client_secret` from `FINCH_CLIENT_SECRET`
+ - `webhook_secret` from `FINCH_WEBHOOK_SECRET`
"""
self.access_token = access_token
@@ -319,6 +333,9 @@ def __init__(
client_secret_envvar = os.environ.get("FINCH_CLIENT_SECRET", None)
self.client_secret = client_secret or client_secret_envvar or None
+ webhook_secret_envvar = os.environ.get("FINCH_WEBHOOK_SECRET", None)
+ self.webhook_secret = webhook_secret or webhook_secret_envvar or None
+
if base_url is None:
base_url = f"https://api.tryfinch.com"
@@ -339,6 +356,7 @@ def __init__(
self.ats = resources.AsyncATS(self)
self.providers = resources.AsyncProviders(self)
self.account = resources.AsyncAccount(self)
+ self.webhooks = resources.AsyncWebhooks(self)
@property
def qs(self) -> Querystring:
@@ -374,6 +392,7 @@ def copy(
*,
client_id: str | None = None,
client_secret: str | None = None,
+ webhook_secret: str | None = None,
access_token: str | None = None,
base_url: str | None = None,
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
@@ -412,6 +431,7 @@ def copy(
return self.__class__(
client_id=client_id or self.client_id,
client_secret=client_secret or self.client_secret,
+ webhook_secret=webhook_secret or self.webhook_secret,
base_url=base_url or str(self.base_url),
access_token=access_token or self.access_token,
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
diff --git a/src/finch/resources/__init__.py b/src/finch/resources/__init__.py
index 4c349ae2..2008f217 100644
--- a/src/finch/resources/__init__.py
+++ b/src/finch/resources/__init__.py
@@ -3,6 +3,18 @@
from .ats import ATS, AsyncATS
from .hris import HRIS, AsyncHRIS
from .account import Account, AsyncAccount
+from .webhooks import Webhooks, AsyncWebhooks
from .providers import Providers, AsyncProviders
-__all__ = ["HRIS", "AsyncHRIS", "ATS", "AsyncATS", "Providers", "AsyncProviders", "Account", "AsyncAccount"]
+__all__ = [
+ "HRIS",
+ "AsyncHRIS",
+ "ATS",
+ "AsyncATS",
+ "Providers",
+ "AsyncProviders",
+ "Account",
+ "AsyncAccount",
+ "Webhooks",
+ "AsyncWebhooks",
+]
diff --git a/src/finch/resources/webhooks.py b/src/finch/resources/webhooks.py
new file mode 100644
index 00000000..f5388ae5
--- /dev/null
+++ b/src/finch/resources/webhooks.py
@@ -0,0 +1,215 @@
+# File generated from our OpenAPI spec by Stainless.
+
+from __future__ import annotations
+
+import hmac
+import json
+import math
+import base64
+import hashlib
+from datetime import datetime, timezone, timedelta
+
+from .._types import HeadersLike
+from .._resource import SyncAPIResource, AsyncAPIResource
+
+__all__ = ["Webhooks", "AsyncWebhooks"]
+
+
+class Webhooks(SyncAPIResource):
+ def unwrap(
+ self,
+ payload: str | bytes,
+ headers: HeadersLike,
+ *,
+ secret: str | None = None,
+ ) -> object:
+ """Validates that the given payload was sent by Finch and parses the payload."""
+ self.verify_signature(payload=payload, headers=headers, secret=secret)
+ return json.loads(payload)
+
+ def verify_signature(
+ self,
+ payload: str | bytes,
+ headers: HeadersLike,
+ *,
+ secret: str | None = None,
+ ) -> None:
+ """Validates whether or not the webhook payload was sent by Finch.
+
+ An error will be raised if the webhook payload was not sent by Finch.
+ """
+ if secret is None:
+ secret = self._client.webhook_secret
+
+ if secret is None:
+ raise ValueError(
+ "The webhook secret must either be set using the env var, FINCH_WEBHOOK_SECRET, on the client class, Finch(webhook_secret='123'), or passed to this function"
+ )
+
+ try:
+ parsedSecret = base64.b64decode(secret)
+ except Exception:
+ raise ValueError("Bad secret")
+
+ msg_id = headers.get("finch-event-id")
+ if not msg_id:
+ raise ValueError("Could not find finch-event-id header")
+
+ msg_timestamp = headers.get("finch-timestamp")
+ if not msg_timestamp:
+ raise ValueError("Could not find finch-timestamp header")
+
+ # validate the timestamp
+ webhook_tolerance = timedelta(minutes=5)
+ now = datetime.now(tz=timezone.utc)
+
+ try:
+ timestamp = datetime.fromtimestamp(float(msg_timestamp), tz=timezone.utc)
+ except Exception:
+ raise ValueError("Invalid timestamp header: " + msg_timestamp + ". Could not convert to timestamp")
+
+ # too old
+ if timestamp < (now - webhook_tolerance):
+ raise ValueError("Webhook timestamp is too old")
+
+ # too new
+ if timestamp > (now + webhook_tolerance):
+ raise ValueError("Webhook timestamp is too new")
+
+ # create the signature
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
+ if not isinstance(body, str): # pyright: ignore[reportUnnecessaryIsInstance]
+ raise ValueError(
+ "Webhook body should be a string of JSON (or bytes which can be decoded to a utf-8 string), not a parsed dictionary."
+ )
+
+ timestamp_str = str(math.floor(timestamp.replace(tzinfo=timezone.utc).timestamp()))
+
+ to_sign = f"{msg_id}.{timestamp_str}.{body}".encode()
+ expected_signature = hmac.new(parsedSecret, to_sign, hashlib.sha256).digest()
+
+ msg_signature = headers.get("finch-signature")
+ if not msg_signature:
+ raise ValueError("Could not find finch-signature header")
+
+ # Signature header can contain multiple signatures delimited by spaces
+ passed_sigs = msg_signature.split(" ")
+
+ for versioned_sig in passed_sigs:
+ values = versioned_sig.split(",")
+ if len(values) != 2:
+ # signature is not formatted like {version},{signature}
+ continue
+
+ (version, signature) = values
+
+ # Only verify prefix v1
+ if version != "v1":
+ continue
+
+ sig_bytes = base64.b64decode(signature)
+ if hmac.compare_digest(expected_signature, sig_bytes):
+ # valid!
+ return None
+
+ raise ValueError("None of the given webhook signatures match the expected signature")
+
+
+class AsyncWebhooks(AsyncAPIResource):
+ def unwrap(
+ self,
+ payload: str | bytes,
+ headers: HeadersLike,
+ *,
+ secret: str | None = None,
+ ) -> object:
+ """Validates that the given payload was sent by Finch and parses the payload."""
+ self.verify_signature(payload=payload, headers=headers, secret=secret)
+ return json.loads(payload)
+
+ def verify_signature(
+ self,
+ payload: str | bytes,
+ headers: HeadersLike,
+ *,
+ secret: str | None = None,
+ ) -> None:
+ """Validates whether or not the webhook payload was sent by Finch.
+
+ An error will be raised if the webhook payload was not sent by Finch.
+ """
+ if secret is None:
+ secret = self._client.webhook_secret
+
+ if secret is None:
+ raise ValueError(
+ "The webhook secret must either be set using the env var, FINCH_WEBHOOK_SECRET, on the client class, Finch(webhook_secret='123'), or passed to this function"
+ )
+
+ try:
+ parsedSecret = base64.b64decode(secret)
+ except Exception:
+ raise ValueError("Bad secret")
+
+ msg_id = headers.get("finch-event-id")
+ if not msg_id:
+ raise ValueError("Could not find finch-event-id header")
+
+ msg_timestamp = headers.get("finch-timestamp")
+ if not msg_timestamp:
+ raise ValueError("Could not find finch-timestamp header")
+
+ # validate the timestamp
+ webhook_tolerance = timedelta(minutes=5)
+ now = datetime.now(tz=timezone.utc)
+
+ try:
+ timestamp = datetime.fromtimestamp(float(msg_timestamp), tz=timezone.utc)
+ except Exception:
+ raise ValueError("Invalid timestamp header: " + msg_timestamp + ". Could not convert to timestamp")
+
+ # too old
+ if timestamp < (now - webhook_tolerance):
+ raise ValueError("Webhook timestamp is too old")
+
+ # too new
+ if timestamp > (now + webhook_tolerance):
+ raise ValueError("Webhook timestamp is too new")
+
+ # create the signature
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
+ if not isinstance(body, str): # pyright: ignore[reportUnnecessaryIsInstance]
+ raise ValueError(
+ "Webhook body should be a string of JSON (or bytes which can be decoded to a utf-8 string), not a parsed dictionary."
+ )
+
+ timestamp_str = str(math.floor(timestamp.replace(tzinfo=timezone.utc).timestamp()))
+
+ to_sign = f"{msg_id}.{timestamp_str}.{body}".encode()
+ expected_signature = hmac.new(parsedSecret, to_sign, hashlib.sha256).digest()
+
+ msg_signature = headers.get("finch-signature")
+ if not msg_signature:
+ raise ValueError("Could not find finch-signature header")
+
+ # Signature header can contain multiple signatures delimited by spaces
+ passed_sigs = msg_signature.split(" ")
+
+ for versioned_sig in passed_sigs:
+ values = versioned_sig.split(",")
+ if len(values) != 2:
+ # signature is not formatted like {version},{signature}
+ continue
+
+ (version, signature) = values
+
+ # Only verify prefix v1
+ if version != "v1":
+ continue
+
+ sig_bytes = base64.b64decode(signature)
+ if hmac.compare_digest(expected_signature, sig_bytes):
+ # valid!
+ return None
+
+ raise ValueError("None of the given webhook signatures match the expected signature")
diff --git a/tests/api_resources/test_webhooks.py b/tests/api_resources/test_webhooks.py
new file mode 100644
index 00000000..61175521
--- /dev/null
+++ b/tests/api_resources/test_webhooks.py
@@ -0,0 +1,216 @@
+# File generated from our OpenAPI spec by Stainless.
+
+from __future__ import annotations
+
+import os
+import base64
+from typing import Any, cast
+from datetime import datetime, timezone, timedelta
+
+import pytest
+import time_machine
+
+from finch import Finch, AsyncFinch
+
+base_url = os.environ.get("API_BASE_URL", "http://127.0.0.1:4010")
+access_token = os.environ.get("API_KEY", "something1234")
+
+
+class TestWebhooks:
+ strict_client = Finch(base_url=base_url, access_token=access_token, _strict_response_validation=True)
+ loose_client = Finch(base_url=base_url, access_token=access_token, _strict_response_validation=False)
+ parametrize = pytest.mark.parametrize("client", [strict_client, loose_client], ids=["strict", "loose"])
+
+ timestamp = "1676312382"
+ fake_now = datetime.fromtimestamp(float(timestamp), tz=timezone.utc)
+
+ payload = """{"company_id":"720be419-0293-4d32-a707-32179b0827ab"}"""
+ signature = "m7y0TV2C+hlHxU42wCieApTSTaA8/047OAplBqxIV/s="
+ headers = {
+ "finch-event-id": "msg_2Lh9KRb0pzN4LePd3XiA4v12Axj",
+ "finch-timestamp": timestamp,
+ "finch-signature": f"v1,{signature}",
+ }
+ secret = "5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH"
+
+ @time_machine.travel(fake_now)
+ def test_unwrap(self) -> None:
+ payload = self.payload
+ headers = self.headers
+ secret = self.secret
+
+ self.strict_client.webhooks.unwrap(payload, headers, secret=secret)
+
+ @time_machine.travel(fake_now)
+ def test_verify_signature(self) -> None:
+ payload = self.payload
+ headers = self.headers
+ secret = self.secret
+ signature = self.signature
+ verify = self.strict_client.webhooks.verify_signature
+
+ assert verify(payload=payload, headers=headers, secret=secret) is None
+
+ with pytest.raises(ValueError, match="Webhook timestamp is too old"):
+ with time_machine.travel(self.fake_now + timedelta(minutes=6)):
+ assert verify(payload=payload, headers=headers, secret=secret) is False
+
+ with pytest.raises(ValueError, match="Webhook timestamp is too new"):
+ with time_machine.travel(self.fake_now - timedelta(minutes=6)):
+ assert verify(payload=payload, headers=headers, secret=secret) is False
+
+ # wrong secret
+ with pytest.raises(ValueError, match=r"Bad secret"):
+ verify(payload=payload, headers=headers, secret="invalid secret")
+
+ invalid_signature_message = "None of the given webhook signatures match the expected signature"
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers=headers,
+ secret=base64.b64encode(b"foo").decode("utf-8"),
+ )
+
+ # multiple signatures
+ invalid_signature = base64.b64encode(b"my_sig").decode("utf-8")
+ assert (
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v1,{invalid_signature} v1,{signature}"},
+ secret=secret,
+ )
+ is None
+ )
+
+ # different signaature version
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v2,{signature}"},
+ secret=secret,
+ )
+
+ assert (
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v2,{signature} v1,{signature}"},
+ secret=secret,
+ )
+ is None
+ )
+
+ # missing version
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": signature},
+ secret=secret,
+ )
+
+ # non-string payload
+ with pytest.raises(ValueError, match=r"Webhook body should be a string"):
+ verify(
+ payload=cast(Any, {"payload": payload}),
+ headers=headers,
+ secret=secret,
+ )
+
+
+class TestAsyncWebhooks:
+ strict_client = AsyncFinch(base_url=base_url, access_token=access_token, _strict_response_validation=True)
+ loose_client = AsyncFinch(base_url=base_url, access_token=access_token, _strict_response_validation=False)
+ parametrize = pytest.mark.parametrize("client", [strict_client, loose_client], ids=["strict", "loose"])
+
+ timestamp = "1676312382"
+ fake_now = datetime.fromtimestamp(float(timestamp), tz=timezone.utc)
+
+ payload = """{"company_id":"720be419-0293-4d32-a707-32179b0827ab"}"""
+ signature = "m7y0TV2C+hlHxU42wCieApTSTaA8/047OAplBqxIV/s="
+ headers = {
+ "finch-event-id": "msg_2Lh9KRb0pzN4LePd3XiA4v12Axj",
+ "finch-timestamp": timestamp,
+ "finch-signature": f"v1,{signature}",
+ }
+ secret = "5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH"
+
+ @time_machine.travel(fake_now)
+ def test_unwrap(self) -> None:
+ payload = self.payload
+ headers = self.headers
+ secret = self.secret
+
+ self.strict_client.webhooks.unwrap(payload, headers, secret=secret)
+
+ @time_machine.travel(fake_now)
+ def test_verify_signature(self) -> None:
+ payload = self.payload
+ headers = self.headers
+ secret = self.secret
+ signature = self.signature
+ verify = self.strict_client.webhooks.verify_signature
+
+ assert verify(payload=payload, headers=headers, secret=secret) is None
+
+ with pytest.raises(ValueError, match="Webhook timestamp is too old"):
+ with time_machine.travel(self.fake_now + timedelta(minutes=6)):
+ assert verify(payload=payload, headers=headers, secret=secret) is False
+
+ with pytest.raises(ValueError, match="Webhook timestamp is too new"):
+ with time_machine.travel(self.fake_now - timedelta(minutes=6)):
+ assert verify(payload=payload, headers=headers, secret=secret) is False
+
+ # wrong secret
+ with pytest.raises(ValueError, match=r"Bad secret"):
+ verify(payload=payload, headers=headers, secret="invalid secret")
+
+ invalid_signature_message = "None of the given webhook signatures match the expected signature"
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers=headers,
+ secret=base64.b64encode(b"foo").decode("utf-8"),
+ )
+
+ # multiple signatures
+ invalid_signature = base64.b64encode(b"my_sig").decode("utf-8")
+ assert (
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v1,{invalid_signature} v1,{signature}"},
+ secret=secret,
+ )
+ is None
+ )
+
+ # different signaature version
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v2,{signature}"},
+ secret=secret,
+ )
+
+ assert (
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": f"v2,{signature} v1,{signature}"},
+ secret=secret,
+ )
+ is None
+ )
+
+ # missing version
+ with pytest.raises(ValueError, match=invalid_signature_message):
+ verify(
+ payload=payload,
+ headers={**headers, "finch-signature": signature},
+ secret=secret,
+ )
+
+ # non-string payload
+ with pytest.raises(ValueError, match=r"Webhook body should be a string"):
+ verify(
+ payload=cast(Any, {"payload": payload}),
+ headers=headers,
+ secret=secret,
+ )