├── .DS_Store ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── receive-webhooks ├── .DS_Store ├── LICENSE ├── Makefile ├── README.md ├── images │ ├── .DS_Store │ ├── architecture-receive-webhooks.png │ └── architecture.png ├── pyproject.toml ├── requirements-dev.txt ├── src │ ├── dependencies │ │ └── requirements.txt │ └── webhook │ │ ├── app │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── exceptions.py │ │ ├── lambda_handler.py │ │ ├── providers │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── column.py │ │ │ ├── dwolla.py │ │ │ ├── github.py │ │ │ ├── lithic.py │ │ │ ├── marqeta.py │ │ │ ├── plaid.py │ │ │ ├── solidfi.py │ │ │ ├── stripe.py │ │ │ ├── treasury_prime.py │ │ │ ├── trolley.py │ │ │ └── unit.py │ │ ├── resources │ │ │ ├── __init__.py │ │ │ ├── dynamodb.py │ │ │ └── s3.py │ │ └── routers │ │ │ ├── __init__.py │ │ │ └── webhook.py │ │ └── requirements.txt └── template.yml └── send-webhooks ├── .gitignore ├── README.md ├── images └── architecture-send-webhooks.png └── template.yaml /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/webhooks/ad49e65587abc7ca88232a3aa37478d4c79ba23e/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .aws-sam 3 | samconfig.toml 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webhooks on AWS: Innovate with event notifications 2 | 3 | This repository is intended for developers looking to send or receive webhooks using AWS. It contains code samples for the reference architectures outlined on [Sending and receiving webhooks on AWS: Innovate with event notifications](https://aws.amazon.com/blogs/compute/sending-and-receiving-webhooks-on-aws-innovate-with-event-notifications/). This includes: 4 | 5 | * [send-webhooks/](/send-webhooks/): An application that delivers webhooks to an external endpoint. 6 | * [receive-webhooks/](/receive-webhooks/): An API that receives webhooks with capacity to handle large payloads. 7 | 8 | If you have any comments, suggestions or feedback, we'd love to [hear from you](https://github.com/aws-samples/webhooks/issues/new). 9 | 10 | ## Architecture: Send Webhooks 11 | 12 | ![An architecture to send webhooks using Amazon EventBridge Pipes](/send-webhooks/images/architecture-send-webhooks.png) 13 | 14 | ## Architecture: Receive Webhooks 15 | 16 | ![An architecture to receive webhooks using the claim-check pattern](/receive-webhooks/images/architecture-receive-webhooks.png) 17 | 18 | ## Security 19 | 20 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 21 | 22 | ## License 23 | 24 | This library is licensed under the MIT-0 License. See the LICENSE file. 25 | 26 | -------------------------------------------------------------------------------- /receive-webhooks/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/webhooks/ad49e65587abc7ca88232a3aa37478d4c79ba23e/receive-webhooks/.DS_Store -------------------------------------------------------------------------------- /receive-webhooks/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /receive-webhooks/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: setup build deploy format clean outdated 2 | 3 | setup: 4 | python3 -m venv .venv 5 | .venv/bin/python3 -m pip install -U pip wheel 6 | .venv/bin/python3 -m pip install -r requirements-dev.txt 7 | .venv/bin/python3 -m pip install -r src/dependencies/requirements.txt 8 | .venv/bin/python3 -m pip install -r src/webhook/requirements.txt 9 | 10 | build: 11 | sam build --use-container --parallel --cached 12 | 13 | deploy: 14 | sam deploy 15 | 16 | clean: 17 | sam delete 18 | 19 | format: 20 | .venv/bin/black . 21 | 22 | outdated: 23 | .venv/bin/python3 -m pip list -o 24 | -------------------------------------------------------------------------------- /receive-webhooks/README.md: -------------------------------------------------------------------------------- 1 | # Receiving Webhooks on AWS 2 | 3 | An example event-driven application which receives webhooks using [serverless on AWS](https://aws.amazon.com/serverless/). 4 | 5 | ## How it works? 6 | 7 | This repository contains an example application with an [Amazon API Gateway](https://aws.amazon.com/api-gateway/) that can be used to receive webhook requests from webhook providers. [AWS Lambda](https://aws.amazon.com/lambda/) is used to verify the requests before persisting the payload into [Amazon S3](https://aws.amazon.com/s3/). Metadata about the S3 object is then stored in [Amazon DynamoDB](https://aws.amazon.com/dynamodb/) for tracking webhook processing. 8 | 9 | ![Reference Architecture](images/architecture.png) 10 | 11 | ## Using Amazon EventBridge Pipes to process webhooks 12 | 13 | You can extend the solution using [Amazon EventBridge Pipes](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes.html). The Pipe can be used to detect changes in the DynamoDB and trigger processing of the webhook using the compute option of your choice as illustrated below. 14 | 15 | ![Reference Architecture](images/architecture-receive-webhooks.png) 16 | 17 | ## Prerequisites 18 | 19 | - [Python 3](https://www.python.org/downloads/) 20 | - [AWS Command Line Interface (AWS CLI)](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) version 2. Please follow these instructions with how to [setup your AWS credentials](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-getting-started-set-up-credentials.html). 21 | - [AWS Serverless Application Model (SAM)](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-getting-started.html) 22 | - [Docker Desktop](https://www.docker.com/products/docker-desktop) 23 | 24 | ## Usage 25 | 26 | ### Parameters 27 | 28 | | Parameter | Type | Default | Description | 29 | | -------------------- | :----: | :-------: | --------------------------------- | 30 | | BasicAuthUser | String | - | Basic authentication user name | 31 | | BasicAuthPassword | String | - | Basic authentication password | 32 | | WebhookSecret | String | - | Webhook secret | 33 | | BucketPrefix | String | raw/ | S3 bucket prefix for payloads | 34 | 35 | ### Setup 36 | 37 | 1. Deploy the application using AWS SAM and follow the instructions. 38 | 39 | ``` 40 | sam deploy --guided 41 | ``` 42 | 43 | 2. Test sending webhooks using the tool of your choice such as Postman or cURL, or use one of the pre-built providers on [src/webhook/app/providers/](/receive-webhooks/src/webhook/app/providers/) such as Plaid or Stripe. 44 | 45 | If you have a provider that you'd love to see, we'd love to [hear from you](https://github.com/aws-samples/webhooks/issues/new). 46 | 47 | ## Clean up 48 | 49 | To avoid unnecessary costs, clean up after using the solution. 50 | 51 | ``` 52 | sam delete 53 | ``` 54 | -------------------------------------------------------------------------------- /receive-webhooks/images/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/webhooks/ad49e65587abc7ca88232a3aa37478d4c79ba23e/receive-webhooks/images/.DS_Store -------------------------------------------------------------------------------- /receive-webhooks/images/architecture-receive-webhooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/webhooks/ad49e65587abc7ca88232a3aa37478d4c79ba23e/receive-webhooks/images/architecture-receive-webhooks.png -------------------------------------------------------------------------------- /receive-webhooks/images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/webhooks/ad49e65587abc7ca88232a3aa37478d4c79ba23e/receive-webhooks/images/architecture.png -------------------------------------------------------------------------------- /receive-webhooks/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | target-version = ['py312'] 4 | include = '\.pyi?$' 5 | extend-exclude = ''' 6 | /( 7 | \.aws-sam 8 | )/ 9 | ''' 10 | -------------------------------------------------------------------------------- /receive-webhooks/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black==24.10.0 2 | aws-lambda-powertools[all,aws-sdk]==3.4.0 3 | boto3-stubs[s3,dynamodb]==1.35.92 -------------------------------------------------------------------------------- /receive-webhooks/src/dependencies/requirements.txt: -------------------------------------------------------------------------------- 1 | lithic==0.81.1 2 | python-jose[cryptography]==3.3.0 3 | stripe==11.4.1 4 | requests==2.32.3 5 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/constants.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from botocore.config import Config 23 | 24 | BOTO3_CONFIG = Config( 25 | signature_version="v4", 26 | s3={ 27 | "addressing_style": "virtual", 28 | "us_east_1_regional_endpoint": "regional", 29 | }, 30 | retries={ 31 | "max_attempts": 10, 32 | "mode": "standard", 33 | }, 34 | tcp_keepalive=True, 35 | ) 36 | 37 | # Environment variables 38 | ENV_BUCKET_NAME = "BUCKET_NAME" 39 | ENV_BUCKET_OWNER_ID = "BUCKET_OWNER_ID" 40 | ENV_BUCKET_PREFIX = "BUCKET_PREFIX" 41 | ENV_KMS_KEY_ID = "KMS_KEY_ID" 42 | ENV_TABLE_NAME = "TABLE_NAME" 43 | ENV_SSM_PARAMETER = "SSM_PARAMETER" 44 | 45 | PARTITION_KEY = "pk" 46 | SORT_KEY = "sk" 47 | 48 | EXPIRES_IN_DAYS = 3 49 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | 23 | class S3PutError(Exception): 24 | pass 25 | 26 | 27 | class S3DeleteError(Exception): 28 | pass 29 | 30 | 31 | class DynamoDBReadError(Exception): 32 | pass 33 | 34 | 35 | class DynamoDBWriteError(Exception): 36 | pass 37 | 38 | 39 | class NotFoundError(Exception): 40 | pass 41 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/lambda_handler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from typing import Dict, Any 23 | 24 | from aws_lambda_powertools import Logger, Tracer 25 | from aws_lambda_powertools.logging import correlation_paths 26 | from aws_lambda_powertools.event_handler import APIGatewayHttpResolver 27 | from aws_lambda_powertools.utilities.typing import LambdaContext 28 | 29 | from app import routers 30 | 31 | 32 | logger = Logger(use_rfc3339=True, utc=True) 33 | tracer = Tracer() 34 | api = APIGatewayHttpResolver() 35 | api.include_router(routers.webhook_router) 36 | 37 | 38 | @tracer.capture_lambda_handler(capture_response=False) 39 | @logger.inject_lambda_context( 40 | log_event=True, correlation_id_path=correlation_paths.API_GATEWAY_HTTP 41 | ) 42 | def handler(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]: 43 | return api.resolve(event, context) 44 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from typing import List 23 | 24 | from .base import BaseProvider 25 | from .column import ColumnProvider 26 | from .dwolla import DwollaProvider 27 | from .github import GithubProvider 28 | from .lithic import LithicProvider 29 | from .marqeta import MarqetaProvider 30 | from .solidfi import SolidProvider 31 | from .stripe import StripeProvider 32 | from .treasury_prime import TreasuryPrimeProvider 33 | from .trolley import TrolleyProvider 34 | from .unit import UnitProvider 35 | 36 | ALL_PROVIDERS: List[BaseProvider] = [ 37 | ColumnProvider, 38 | DwollaProvider, 39 | GithubProvider, 40 | LithicProvider, 41 | MarqetaProvider, 42 | SolidProvider, 43 | StripeProvider, 44 | TreasuryPrimeProvider, 45 | TrolleyProvider, 46 | UnitProvider, 47 | ] 48 | 49 | PROVIDER_MAP = {provider.get_provider_name(): provider for provider in ALL_PROVIDERS} 50 | 51 | __all__ = [PROVIDER_MAP] 52 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/base.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | import binascii 23 | import base64 24 | from dataclasses import dataclass 25 | import hmac 26 | import os 27 | from typing import Optional, Dict, Any 28 | 29 | from aws_lambda_powertools import Logger 30 | from aws_lambda_powertools.utilities import parameters 31 | from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent 32 | import boto3 33 | 34 | from app import resources, constants, exceptions 35 | 36 | __all__ = ["BaseProvider", "HTTPBasicCredentials"] 37 | 38 | logger = Logger(child=True) 39 | 40 | SSM_PARAMETER = os.getenv(constants.ENV_SSM_PARAMETER) 41 | 42 | 43 | @dataclass(slots=True, frozen=True) 44 | class HTTPBasicCredentials: 45 | username: str 46 | password: str 47 | 48 | 49 | class BaseProvider: 50 | SIGNATURE_HEADER: Optional[str] = None 51 | SIGNATURE_ALGO: Optional[str] = None 52 | SIGNATURE_ENCODING: Optional[str] = None 53 | PARAMETER_KEY: Optional[str] = "webhook_secret" 54 | 55 | def __init__(self, event: BaseProxyEvent, session: Optional[boto3.Session] = None) -> None: 56 | self._event = event 57 | if not session: 58 | session = boto3._get_default_session() 59 | self._client = resources.DynamoDB(session) 60 | 61 | @classmethod 62 | def get_provider_name(cls) -> str: 63 | raise NotImplementedError 64 | 65 | def verify(self) -> bool: 66 | if not self.SIGNATURE_HEADER or not self.SIGNATURE_ALGO: 67 | raise NotImplementedError 68 | 69 | signature = self.parse_signature(self._event.get_header_value(self.SIGNATURE_HEADER)) 70 | if not signature: 71 | logger.warning(f"Signature header {self.SIGNATURE_HEADER} not found") 72 | return False 73 | 74 | payload = self._event.decoded_body 75 | if not payload: 76 | logger.warning("Missing payload body") 77 | return False 78 | payload_bytes = payload.encode() 79 | 80 | parameter = self.get_parameter() 81 | key = bytes(parameter[self.PARAMETER_KEY], "utf-8") 82 | 83 | if self.SIGNATURE_ENCODING == "base64": 84 | computed_signature = hmac.new(key, payload_bytes, self.SIGNATURE_ALGO).digest() 85 | computed_signature = base64.encodebytes(computed_signature).decode().rstrip() 86 | else: 87 | computed_signature = hmac.new(key, payload_bytes, self.SIGNATURE_ALGO).hexdigest() 88 | 89 | if not hmac.compare_digest(signature, computed_signature): 90 | logger.warning( 91 | "Computed signature did not match provided signature", 92 | signature=signature, 93 | computed_signature=computed_signature, 94 | ) 95 | return False 96 | 97 | return True 98 | 99 | def get_event_id(self) -> Optional[str]: 100 | """ 101 | Return the unique ID for this event 102 | """ 103 | raise NotImplementedError 104 | 105 | def get_parameter(self) -> Dict[str, Any]: 106 | if not SSM_PARAMETER: 107 | return {} 108 | 109 | logger.debug(f"Fetching parameter: {SSM_PARAMETER}") 110 | return parameters.get_parameter(SSM_PARAMETER, transform="json") 111 | 112 | def is_duplicate(self, event_id: Optional[str]) -> bool: 113 | if not event_id: 114 | # if we don't have a unique event ID, treat the event as not a duplicate 115 | return False 116 | 117 | key = { 118 | constants.PARTITION_KEY: self.get_provider_name().upper(), 119 | constants.SORT_KEY: event_id, 120 | } 121 | try: 122 | self._client.get_item(key, attributes=[constants.PARTITION_KEY]) 123 | except exceptions.NotFoundError: 124 | return False 125 | 126 | return True 127 | 128 | def extract_authorization(self, authorization: str) -> Optional[HTTPBasicCredentials]: 129 | scheme, _, param = authorization.partition(" ") 130 | if not authorization or scheme.lower() != "basic": 131 | return None 132 | 133 | try: 134 | data = base64.b64decode(param).decode("ascii") 135 | except (ValueError, UnicodeDecodeError, binascii.Error): 136 | logger.warning("Unable to base64 decode header", param=param) 137 | return None 138 | 139 | username, separator, password = data.partition(":") 140 | if not separator: 141 | logger.warning("No separator found", separator=separator) 142 | return None 143 | 144 | return HTTPBasicCredentials(username, password) 145 | 146 | def parse_signature(self, signature: Optional[str]) -> Optional[str]: 147 | return signature 148 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/column.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from typing import Optional, Dict, Any, Literal 23 | 24 | from app.providers.base import BaseProvider 25 | 26 | __all__ = ["ColumnProvider"] 27 | 28 | 29 | # @see https://column.com/docs/workingwithapi/events-and-webhooks#authentication 30 | class ColumnProvider(BaseProvider): 31 | SIGNATURE_HEADER = "Column-Signature" 32 | SIGNATURE_ALGO = "sha256" 33 | 34 | @classmethod 35 | def get_provider_name(cls) -> Literal["column"]: 36 | return "column" 37 | 38 | def get_event_id(self) -> Optional[str]: 39 | data: Dict[str, Any] = self._event.json_body 40 | return data.get("id") 41 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/dwolla.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from typing import Optional, Dict, Any, Literal 23 | 24 | from app.providers.base import BaseProvider 25 | 26 | __all__ = ["DwollaProvider"] 27 | 28 | 29 | # @see https://developers.dwolla.com/docs/balance/webhooks/process-validate#authentication 30 | class DwollaProvider(BaseProvider): 31 | SIGNATURE_HEADER = "X-Request-Signature-SHA-256" 32 | SIGNATURE_ALGO = "sha256" 33 | 34 | @classmethod 35 | def get_provider_name(cls) -> Literal["dwolla"]: 36 | return "dwolla" 37 | 38 | def get_event_id(self) -> Optional[str]: 39 | data: Dict[str, Any] = self._event.json_body 40 | return data.get("id") 41 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/github.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | __all__ = ["GithubProvider"] 23 | 24 | from typing import Literal, Optional 25 | 26 | from app.providers import BaseProvider 27 | 28 | 29 | class GithubProvider(BaseProvider): 30 | SIGNATURE_HEADER = "X-Hub-Signature-256" 31 | SIGNATURE_ALGO = "sha256" 32 | 33 | @classmethod 34 | def get_provider_name(cls) -> Literal["github"]: 35 | return "github" 36 | 37 | def get_event_id(self) -> Optional[str]: 38 | return self._event.headers.get("X-GitHub-Delivery") 39 | 40 | def parse_signature(self, signature: Optional[str]) -> Optional[str]: 41 | if signature and signature.startswith("sha256="): 42 | signature = signature[7:] 43 | return signature 44 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/lithic.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from typing import Literal 23 | 24 | from aws_lambda_powertools import Logger 25 | from lithic import Lithic 26 | 27 | from app.providers.base import BaseProvider 28 | 29 | logger = Logger(child=True) 30 | 31 | __all__ = ["LithicProvider"] 32 | 33 | 34 | # @see https://docs.lithic.com/docs/events-api#example-code 35 | class LithicProvider(BaseProvider): 36 | @classmethod 37 | def get_provider_name(cls) -> Literal["lithic"]: 38 | return "lithic" 39 | 40 | def get_event_id(self) -> str | None: 41 | return self._event.get_header_value("webhook-id") 42 | 43 | def verify(self) -> bool: 44 | payload = self._event.decoded_body 45 | 46 | parameter = self.get_parameter() 47 | secret: str = parameter["webhook_secret"] 48 | 49 | client = Lithic() 50 | try: 51 | client.webhooks.unwrap(payload, self._event.headers, secret) 52 | except Exception as error: 53 | logger.warning("Error verifying webhook signature", error) 54 | return False 55 | 56 | raise True 57 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/marqeta.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from typing import Optional, Literal 23 | 24 | from aws_lambda_powertools import Logger 25 | 26 | from app.providers.base import BaseProvider, HTTPBasicCredentials 27 | 28 | __all__ = ["MarqetaProvider"] 29 | 30 | logger = Logger(child=True) 31 | 32 | 33 | # @see https://www.marqeta.com/docs/developer-guides/signature-verification 34 | class MarqetaProvider(BaseProvider): 35 | SIGNATURE_HEADER = "X-Marqeta-Signature" 36 | SIGNATURE_ALGO = "sha1" 37 | 38 | @classmethod 39 | def get_provider_name(cls) -> Literal["marqeta"]: 40 | return "marqeta" 41 | 42 | def get_event_id(self) -> Optional[str]: 43 | return self._event.get_header_value("x-marqeta-request-trace-id") 44 | 45 | def verify(self) -> bool: 46 | authorization = self._event.get_header_value("Authorization") 47 | if not authorization: 48 | logger.warning(f"Authorization header not found") 49 | return False 50 | 51 | parameter = self.get_parameter() 52 | 53 | expected = HTTPBasicCredentials( 54 | parameter["basic_auth_user"], parameter["basic_auth_password"] 55 | ) 56 | 57 | actual = self.extract_authorization(authorization) 58 | 59 | if expected != actual: 60 | logger.warning("Encoded values did not match", expected=expected, actual=actual) 61 | return False 62 | 63 | # Marqeta uses both an Authorization header and a signature header. After validating 64 | # the Authorization header, we need to verify the signature. 65 | return super().verify() 66 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/plaid.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | import hmac 23 | import hashlib 24 | import time 25 | from typing import Optional, Dict, Any, Literal 26 | 27 | from aws_lambda_powertools import Logger 28 | import requests 29 | from jose import jwt 30 | 31 | from app.providers.base import BaseProvider 32 | 33 | logger = Logger(child=True) 34 | 35 | __all__ = ["PlaidProvider"] 36 | 37 | KEY_CACHE = {} 38 | 39 | 40 | # @see https://plaid.com/docs/api/webhooks/webhook-verification/ 41 | class PlaidProvider(BaseProvider): 42 | SIGNATURE_HEADER = "plaid-verification" 43 | # Endpoint for getting public verification keys. 44 | ENDPOINT = "https://production.plaid.com/webhook_verification_key/get" 45 | 46 | @classmethod 47 | def get_provider_name(cls) -> Literal["plaid"]: 48 | return "plaid" 49 | 50 | def get_event_id(self) -> Optional[str]: 51 | data: Dict[str, Any] = self._event.json_body 52 | return data.get("item_id") 53 | 54 | def verify(self) -> bool: 55 | signed_jwt = self._event.get_header_value(self.SIGNATURE_HEADER) 56 | if not signed_jwt: 57 | logger.warning(f"Signature header {self.SIGNATURE_HEADER} not found") 58 | return False 59 | current_key_id = jwt.get_unverified_header(signed_jwt)["kid"] 60 | 61 | parameter = self.get_parameter() 62 | 63 | # If the key is not in the cache, update all non-expired keys. 64 | if current_key_id not in KEY_CACHE: 65 | keys_ids_to_update = [ 66 | key_id for key_id, key in KEY_CACHE.items() if key["expired_at"] is None 67 | ] 68 | 69 | keys_ids_to_update.append(current_key_id) 70 | 71 | for key_id in keys_ids_to_update: 72 | r = requests.post( 73 | self.ENDPOINT, 74 | json={ 75 | "client_id": parameter["client_id"], 76 | "secret": parameter["client_secret"], 77 | "key_id": key_id, 78 | }, 79 | ) 80 | 81 | # If this is the case, the key ID may be invalid. 82 | if r.status_code != 200: 83 | continue 84 | 85 | response = r.json() 86 | key = response["key"] 87 | KEY_CACHE[key_id] = key 88 | 89 | # If the key ID is not in the cache, the key ID may be invalid. 90 | if current_key_id not in KEY_CACHE: 91 | return False 92 | 93 | # Fetch the current key from the cache. 94 | key = KEY_CACHE[current_key_id] 95 | 96 | # Reject expired keys. 97 | if key["expired_at"] is not None: 98 | return False 99 | 100 | # Validate the signature and extract the claims. 101 | try: 102 | claims = jwt.decode(signed_jwt, key, algorithms=["ES256"]) 103 | except jwt.JWTError: 104 | return False 105 | 106 | # Ensure that the token is not expired. 107 | if claims["iat"] < time.time() - 5 * 60: 108 | return False 109 | 110 | body = self._event.decoded_body 111 | 112 | # Compute the hash of the body. 113 | m = hashlib.sha256() 114 | m.update(body.encode()) 115 | body_hash = m.hexdigest() 116 | 117 | # Ensure that the hash of the body matches the claim. 118 | # Use constant time comparison to prevent timing attacks. 119 | return hmac.compare_digest(body_hash, claims["request_body_sha256"]) 120 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/solidfi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from typing import Optional, Dict, Any, Literal 23 | 24 | from app.providers.base import BaseProvider 25 | 26 | __all__ = ["SolidProvider"] 27 | 28 | 29 | class SolidProvider(BaseProvider): 30 | SIGNATURE_HEADER = "sd-webhook-sha256-signature" 31 | SIGNATURE_ALGO = "sha256" 32 | 33 | @classmethod 34 | def get_provider_name(cls) -> Literal["solidfi"]: 35 | return "solidfi" 36 | 37 | def get_event_id(self) -> Optional[str]: 38 | data: Dict[str, Any] = self._event.json_body 39 | return data.get("data", {}).get("id") 40 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/stripe.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from typing import Optional, Dict, Any, Literal 23 | 24 | from aws_lambda_powertools import Logger 25 | import stripe 26 | 27 | from app.providers.base import BaseProvider 28 | 29 | logger = Logger(child=True) 30 | 31 | __all__ = ["StripeProvider"] 32 | 33 | 34 | # @see https://stripe.com/docs/webhooks#verify-official-libraries 35 | class StripeProvider(BaseProvider): 36 | SIGNATURE_HEADER = "Stripe-Signature" 37 | SIGNATURE_ALGO = "sha256" 38 | 39 | @classmethod 40 | def get_provider_name(cls) -> Literal["stripe"]: 41 | return "stripe" 42 | 43 | def get_event_id(self) -> Optional[str]: 44 | data: Dict[str, Any] = self._event.json_body 45 | return data.get("id") 46 | 47 | def verify(self) -> bool: 48 | signature = self._event.get_header_value(self.SIGNATURE_HEADER) 49 | if not signature: 50 | logger.warning(f"Signature header {self.SIGNATURE_HEADER} not found") 51 | return False 52 | 53 | payload = self._event.decoded_body 54 | if not payload: 55 | logger.warning("Missing payload body") 56 | return False 57 | 58 | parameter = self.get_parameter() 59 | secret = parameter["webhook_secret"] 60 | 61 | try: 62 | stripe.Webhook.construct_event(payload, signature, secret) 63 | except ValueError as error: 64 | logger.warning("Invalid payload", error) 65 | return False 66 | except stripe.error.SignatureVerificationError as error: 67 | logger.warning("Error verifying webhook signature", error) 68 | return False 69 | 70 | return True 71 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/treasury_prime.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from typing import Optional, Dict, Any, Literal 23 | 24 | from aws_lambda_powertools import Logger 25 | 26 | from app.providers.base import BaseProvider, HTTPBasicCredentials 27 | 28 | __all__ = ["TreasuryPrimeProvider"] 29 | 30 | logger = Logger(child=True) 31 | 32 | 33 | # @see https://developers.treasuryprime.com/docs/webhooks#validating-webhooks 34 | class TreasuryPrimeProvider(BaseProvider): 35 | SIGNATURE_HEADER = "Authorization" 36 | 37 | @classmethod 38 | def get_provider_name(cls) -> Literal["treasury_prime"]: 39 | return "treasury_prime" 40 | 41 | def get_event_id(self) -> Optional[str]: 42 | data: Dict[str, Any] = self._event.json_body 43 | return data.get("id") 44 | 45 | def verify(self) -> bool: 46 | authorization = self._event.get_header_value(self.SIGNATURE_HEADER) 47 | if not authorization: 48 | logger.warning(f"Authorization header not found") 49 | return False 50 | 51 | parameter = self.get_parameter() 52 | 53 | expected = HTTPBasicCredentials( 54 | parameter["basic_auth_user"], parameter["basic_auth_password"] 55 | ) 56 | 57 | actual = self.extract_authorization(authorization) 58 | 59 | if expected != actual: 60 | logger.warning("Encoded values did not match", expected=expected, actual=actual) 61 | return False 62 | 63 | return True 64 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/trolley.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | import hmac 23 | from typing import Optional, Literal 24 | 25 | from aws_lambda_powertools import Logger 26 | 27 | from app.providers.base import BaseProvider 28 | 29 | __all__ = ["TrolleyProvider"] 30 | 31 | logger = Logger(child=True) 32 | 33 | 34 | # @see https://docs.trolley.com/api/#webhooks-verify 35 | class TrolleyProvider(BaseProvider): 36 | SIGNATURE_HEADER = "X-PaymentRails-Signature" 37 | SIGNATURE_ALGO = "sha256" 38 | 39 | @classmethod 40 | def get_provider_name(cls) -> Literal["trolley"]: 41 | return "trolley" 42 | 43 | def get_event_id(self) -> Optional[str]: 44 | return self._event.get_header_value("X-PaymentRails-Delivery") 45 | 46 | def verify(self) -> bool: 47 | signature = self._event.get_header_value(self.SIGNATURE_HEADER) 48 | if not signature: 49 | logger.warning(f"Signature header {self.SIGNATURE_HEADER} not found") 50 | return False 51 | 52 | payload = self._event.decoded_body 53 | if not payload: 54 | logger.warning("Missing payload body") 55 | return False 56 | 57 | parameter = self.get_parameter() 58 | key = bytes(parameter[self.PARAMETER_KEY], "utf-8") 59 | 60 | sig_values = signature.split(",") 61 | timestamp = sig_values[0].split("=")[1] 62 | v1 = sig_values[1].split("=")[1] 63 | 64 | computed_signature = hmac.new(key, timestamp + payload, self.SIGNATURE_ALGO).hexdigest() 65 | 66 | if not hmac.compare_digest(v1, computed_signature): 67 | logger.warning( 68 | "Computed signature did not match provided signature", 69 | signature=v1, 70 | computed_signature=computed_signature, 71 | ) 72 | return False 73 | 74 | return True 75 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/providers/unit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from typing import Optional, Dict, Any, Literal 23 | 24 | from app.providers.base import BaseProvider 25 | 26 | __all__ = ["UnitProvider"] 27 | 28 | 29 | # @see https://docs.unit.co/webhooks#securing-your-webhooks 30 | class UnitProvider(BaseProvider): 31 | SIGNATURE_HEADER = "x-unit-signature" 32 | SIGNATURE_ALGO = "sha1" 33 | SIGNATURE_ENCODING = "base64" 34 | 35 | @classmethod 36 | def get_provider_name(cls) -> Literal["unit"]: 37 | return "unit" 38 | 39 | def get_event_id(self) -> Optional[str]: 40 | data: Dict[str, Any] = self._event.json_body 41 | return data.get("id") 42 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/resources/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from .dynamodb import DynamoDB 23 | from .s3 import S3, S3Object 24 | 25 | __all__ = ["DynamoDB", "S3", "S3Object"] 26 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/resources/dynamodb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | import os 23 | from typing import TYPE_CHECKING, Dict, Any, Optional, List 24 | 25 | from aws_lambda_powertools import Logger 26 | import boto3 27 | import botocore 28 | from boto3.dynamodb.types import TypeDeserializer, TypeSerializer 29 | 30 | if TYPE_CHECKING: 31 | from mypy_boto3_dynamodb import DynamoDBClient 32 | 33 | from app import constants, exceptions 34 | 35 | __all__ = ["DynamoDB"] 36 | 37 | logger = Logger(child=True) 38 | TABLE_NAME = os.getenv(constants.ENV_TABLE_NAME) 39 | 40 | 41 | class DynamoDB: 42 | _deserializer = TypeDeserializer() 43 | _serializer = TypeSerializer() 44 | 45 | def __init__(self, session: boto3.Session) -> None: 46 | self._client: "DynamoDBClient" = session.client("dynamodb", config=constants.BOTO3_CONFIG) 47 | 48 | def put_item(self, item: Dict[str, Any]) -> None: 49 | params = { 50 | "TableName": TABLE_NAME, 51 | "Item": self.serialize(item), 52 | } 53 | 54 | logger.debug("put_item", params=params) 55 | try: 56 | self._client.put_item(**params) 57 | except botocore.exceptions.ClientError as error: 58 | logger.exception("Unable to put item", error) 59 | raise exceptions.DynamoDBWriteError("Unable to put item") 60 | 61 | def get_item( 62 | self, key: Dict[str, Any], attributes: Optional[List[str]] = None 63 | ) -> Dict[str, Any]: 64 | params = { 65 | "TableName": TABLE_NAME, 66 | "Key": self.serialize(key), 67 | } 68 | if attributes: 69 | params["ExpressionAttributeNames"] = {} 70 | placeholders: List[str] = [] 71 | for idx, attribute in enumerate(attributes): 72 | placeholder = f"#a{idx}" 73 | params["ExpressionAttributeNames"][placeholder] = attribute 74 | placeholders.append(placeholder) 75 | params["ProjectionExpression"] = ",".join(placeholders) 76 | 77 | logger.debug("get_item", params=params) 78 | 79 | try: 80 | response = self._client.get_item(**params) 81 | except botocore.exceptions.ClientError as error: 82 | logger.exception("Unable to get item", error) 83 | raise exceptions.DynamoDBReadError("Unable to get item") 84 | 85 | item: Dict[str, Any] = response.get("Item", {}) 86 | if not item: 87 | raise exceptions.NotFoundError("Item not found") 88 | 89 | return self.deserialize(item) 90 | 91 | @classmethod 92 | def deserialize(cls, item: Any) -> Any: 93 | if not item: 94 | return item 95 | 96 | if isinstance(item, dict) and "M" not in item: 97 | item = {"M": item} 98 | 99 | return cls._deserializer.deserialize(item) 100 | 101 | @classmethod 102 | def serialize(cls, obj: Any) -> Dict[str, Any]: 103 | result = cls._serializer.serialize(obj) 104 | if "M" in result: 105 | result: Dict[str, Any] = result["M"] 106 | return result 107 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/resources/s3.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | import base64 23 | from dataclasses import dataclass 24 | import hashlib 25 | import os 26 | from typing import Dict, TYPE_CHECKING, Optional 27 | 28 | from aws_lambda_powertools import Logger 29 | import boto3 30 | import botocore 31 | 32 | if TYPE_CHECKING: 33 | from mypy_boto3_s3 import S3Client 34 | 35 | from app import constants, exceptions 36 | 37 | __all__ = ["S3", "S3Object"] 38 | 39 | logger = Logger(child=True) 40 | BUCKET_NAME = os.getenv(constants.ENV_BUCKET_NAME) 41 | BUCKET_OWNER_ID = os.getenv(constants.ENV_BUCKET_OWNER_ID) 42 | KMS_KEY_ID = os.getenv(constants.ENV_KMS_KEY_ID) 43 | 44 | 45 | @dataclass(kw_only=True, slots=True, frozen=True) 46 | class S3Object: 47 | bucket: str 48 | key: str 49 | version_id: str 50 | 51 | 52 | class S3: 53 | def __init__(self, session: boto3.Session) -> None: 54 | self._client: "S3Client" = session.client("s3", config=constants.BOTO3_CONFIG) 55 | 56 | def put_object( 57 | self, 58 | key: str, 59 | body: str, 60 | metadata: Optional[Dict[str, str]] = None, 61 | content_type: Optional[str] = "application/json", 62 | ) -> S3Object: 63 | checksum_md5 = self._checksum_algo(body, "md5") 64 | checksum_sha1 = self._checksum_algo(body, "sha1") 65 | 66 | params = { 67 | "ACL": "bucket-owner-full-control", 68 | "Body": body, 69 | "Bucket": BUCKET_NAME, 70 | "ContentMD5": checksum_md5, 71 | "ChecksumAlgorithm": "SHA1", 72 | "ChecksumSHA1": checksum_sha1, 73 | "Key": key, 74 | "ServerSideEncryption": "aws:kms", 75 | "SSEKMSKeyId": KMS_KEY_ID, 76 | "StorageClass": "STANDARD_IA", 77 | } 78 | if content_type: 79 | params["ContentType"] = content_type 80 | if metadata: 81 | params["Metadata"] = metadata 82 | if BUCKET_OWNER_ID: 83 | params["ExpectedBucketOwner"] = BUCKET_OWNER_ID 84 | 85 | logger.debug("put_object", params=params) 86 | try: 87 | response = self._client.put_object(**params) 88 | except botocore.exceptions.ClientError as error: 89 | logger.exception("Failed to write object to S3", error) 90 | raise exceptions.S3PutError() 91 | 92 | return S3Object(bucket=BUCKET_NAME, key=key, version_id=response["VersionId"]) 93 | 94 | def delete_object(self, key: str, version_id: Optional[str] = None) -> None: 95 | params = { 96 | "Bucket": BUCKET_NAME, 97 | "Key": key, 98 | } 99 | if version_id: 100 | params["VersionId"] = version_id 101 | if BUCKET_OWNER_ID: 102 | params["ExpectedBucketOwner"] = BUCKET_OWNER_ID 103 | 104 | logger.debug("delete_object", params=params) 105 | try: 106 | self._client.delete_object(**params) 107 | except botocore.exceptions.ClientError as error: 108 | logger.exception("Failed to delete object from S3", error) 109 | raise exceptions.S3DeleteError() 110 | 111 | @classmethod 112 | def _checksum_algo(cls, data: str, algo: str = "md5") -> str: 113 | hash = hashlib.new(algo, bytes(data, "utf-8")).digest() 114 | return base64.b64encode(hash).decode() 115 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/routers/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from .webhook import router as webhook_router 23 | 24 | __all__ = ["webhook_router"] 25 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/app/routers/webhook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | * SPDX-License-Identifier: MIT-0 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 9 | * software and associated documentation files (the "Software"), to deal in the Software 10 | * without restriction, including without limitation the rights to use, copy, modify, 11 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | * permit persons to whom the Software is furnished to do so. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | """ 21 | 22 | from datetime import datetime, timezone, timedelta 23 | import math 24 | 25 | from aws_lambda_powertools import Logger, Tracer 26 | from aws_lambda_powertools.event_handler.api_gateway import Router, Response 27 | from aws_lambda_powertools.event_handler.exceptions import InternalServerError, BadRequestError, UnauthorizedError 28 | import boto3 29 | 30 | from app import providers, exceptions, constants, resources 31 | 32 | __all__ = ["router"] 33 | 34 | logger = Logger(child=True) 35 | tracer = Tracer() 36 | router = Router() 37 | 38 | session = boto3._get_default_session() 39 | s3 = resources.S3(session) 40 | dynamodb = resources.DynamoDB(session) 41 | 42 | 43 | @tracer.capture_method(capture_response=False) 44 | @router.post("/") 45 | def post_webhook(provider: str) -> Response: 46 | event = router.current_event 47 | if not event.body: 48 | logger.warning("No payload found in request") 49 | raise BadRequestError("No payload found in request") 50 | 51 | provider_class = providers.PROVIDER_MAP.get(provider) 52 | if not provider_class: 53 | logger.warning(f"Unknown provider: {provider}") 54 | raise BadRequestError( 55 | f"Unknown provider: {provider} (only {providers.PROVIDER_MAP.keys()} are supported)" 56 | ) 57 | 58 | prov: providers.BaseProvider = provider_class(event) 59 | logger.debug(f"Using provider: {prov.get_provider_name()}") 60 | if not prov.verify(): 61 | raise UnauthorizedError("Signature verification failed") 62 | 63 | event_id = prov.get_event_id() 64 | if prov.is_duplicate(event_id): 65 | logger.warning("Duplicate webhook request, replying with 200", event_id=event_id) 66 | return Response(200) 67 | 68 | now = datetime.now(tz=timezone.utc).replace(microsecond=0) 69 | expires_at = now + timedelta(days=constants.EXPIRES_IN_DAYS) 70 | arrived_at = now.isoformat().replace("+00:00", "Z") 71 | if not event_id: 72 | # if there is no unique event ID, use the arrival time 73 | event_id = arrived_at 74 | 75 | key = f"raw/{provider}/evt_{event_id}.json" 76 | metadata = { 77 | "event_id": str(event_id), 78 | "arrived_at": str(arrived_at), 79 | "provider": provider, 80 | "expires_at": str(math.floor(expires_at.timestamp())), 81 | } 82 | try: 83 | obj = s3.put_object(key, event.decoded_body, metadata) 84 | except exceptions.S3PutError: 85 | raise InternalServerError("Failed to store request payload") 86 | 87 | item = { 88 | constants.PARTITION_KEY: provider.upper(), 89 | constants.SORT_KEY: event_id, 90 | "arrived_at": arrived_at, 91 | "provider": provider, 92 | "s3": { 93 | "bucket": obj.bucket, 94 | "key": obj.key, 95 | "version_id": obj.version_id, 96 | }, 97 | "gsi1pk": "PENDING", 98 | "gsi1sk": arrived_at, 99 | "expires_at": math.floor(expires_at.timestamp()), 100 | } 101 | try: 102 | dynamodb.put_item(item) 103 | except exceptions.DynamoDBWriteError: 104 | # Remove previously uploaded S3 object 105 | try: 106 | s3.delete_object(obj.key, obj.version_id) 107 | except exceptions.S3DeleteError: 108 | pass 109 | raise InternalServerError("Failed to store request metadata") 110 | 111 | return Response(200) 112 | -------------------------------------------------------------------------------- /receive-webhooks/src/webhook/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/webhooks/ad49e65587abc7ca88232a3aa37478d4c79ba23e/receive-webhooks/src/webhook/requirements.txt -------------------------------------------------------------------------------- /receive-webhooks/template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: "AWS::Serverless-2016-10-31" 3 | Description: Sample architecture to receive webhooks 4 | 5 | Parameters: 6 | BasicAuthUser: 7 | Type: String 8 | Description: Basic Authentication user name 9 | Default: "" 10 | NoEcho: true 11 | BasicAuthPassword: 12 | Type: String 13 | Description: Basic Authentication password 14 | Default: "" 15 | NoEcho: true 16 | WebhookSecret: 17 | Type: String 18 | Description: Webhook Secret 19 | NoEcho: true 20 | BucketPrefix: 21 | Type: String 22 | Description: S3 Bucket Prefix 23 | Default: "raw/" 24 | 25 | Globals: 26 | Function: 27 | Architectures: 28 | - arm64 29 | Environment: 30 | Variables: 31 | LOG_LEVEL: info 32 | Handler: app.lambda_handler.handler 33 | Layers: 34 | - "{{resolve:ssm:/aws/service/powertools/python/arm64/python3.13/latest}}" 35 | MemorySize: 128 # megabytes 36 | Runtime: python3.13 37 | Timeout: 5 # seconds 38 | Tracing: Active 39 | 40 | Resources: 41 | DependencyLayer: 42 | Type: "AWS::Serverless::LayerVersion" 43 | Metadata: 44 | BuildMethod: python3.13 45 | BuildArchitecture: arm64 46 | Properties: 47 | LicenseInfo: MIT-0 48 | CompatibleArchitectures: 49 | - arm64 50 | CompatibleRuntimes: 51 | - python3.13 52 | ContentUri: src/dependencies 53 | Description: !Sub "${AWS::StackName} - Dependency Layer" 54 | RetentionPolicy: Delete 55 | 56 | EncryptionKey: 57 | Type: "AWS::KMS::Key" 58 | UpdateReplacePolicy: Delete 59 | DeletionPolicy: Delete 60 | Properties: 61 | Description: !Sub "${AWS::StackName} - Encryption Key" 62 | Enabled: true 63 | EnableKeyRotation: true 64 | KeyPolicy: 65 | Version: "2012-10-17" 66 | Statement: 67 | - Sid: Enable IAM User Permissions 68 | Effect: Allow 69 | Principal: 70 | AWS: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:root" 71 | Action: "kms:*" 72 | Resource: "*" 73 | - Sid: Allow encryption by webhoook function 74 | Effect: Allow 75 | Principal: 76 | AWS: !GetAtt WebhookFunctionRole.Arn 77 | Action: 78 | - "kms:DescribeKey" 79 | - "kms:Encrypt" 80 | - "kms:GenerateDataKey" 81 | Resource: "*" 82 | KeySpec: SYMMETRIC_DEFAULT 83 | KeyUsage: ENCRYPT_DECRYPT 84 | MultiRegion: false 85 | PendingWindowInDays: 7 86 | 87 | EncryptionAlias: 88 | Type: "AWS::KMS::Alias" 89 | Properties: 90 | AliasName: !Sub "alias/${AWS::StackName}" 91 | TargetKeyId: !Ref EncryptionKey 92 | 93 | WebhookParameter: 94 | Type: "AWS::SSM::Parameter" 95 | Properties: 96 | Description: Webhook Credential 97 | Name: "/webhook/credentials" 98 | Type: String 99 | Value: !Sub |- 100 | { 101 | "basic_auth_user": "${BasicAuthUser}", 102 | "basic_auth_password": "${BasicAuthPassword}", 103 | "webhook_secret": "${WebhookSecret}" 104 | } 105 | 106 | Table: 107 | Type: "AWS::DynamoDB::GlobalTable" 108 | UpdateReplacePolicy: Delete 109 | DeletionPolicy: Delete 110 | Properties: 111 | AttributeDefinitions: 112 | - AttributeName: pk 113 | AttributeType: S 114 | - AttributeName: sk 115 | AttributeType: S 116 | - AttributeName: gsi1pk 117 | AttributeType: S 118 | - AttributeName: gsi1sk 119 | AttributeType: S 120 | BillingMode: PAY_PER_REQUEST 121 | KeySchema: 122 | - AttributeName: pk 123 | KeyType: HASH 124 | - AttributeName: sk 125 | KeyType: RANGE 126 | GlobalSecondaryIndexes: 127 | - IndexName: gsi1 128 | KeySchema: 129 | - AttributeName: gsi1pk 130 | KeyType: HASH 131 | - AttributeName: gsi1sk 132 | KeyType: RANGE 133 | Projection: 134 | ProjectionType: ALL 135 | Replicas: 136 | - PointInTimeRecoverySpecification: 137 | PointInTimeRecoveryEnabled: true 138 | Region: !Ref "AWS::Region" 139 | TableClass: STANDARD 140 | SSESpecification: 141 | SSEEnabled: true 142 | StreamSpecification: 143 | StreamViewType: NEW_AND_OLD_IMAGES 144 | TimeToLiveSpecification: 145 | AttributeName: expire_at 146 | Enabled: true 147 | 148 | HttpApi: 149 | Type: "AWS::Serverless::HttpApi" 150 | Properties: 151 | CorsConfiguration: 152 | AllowHeaders: 153 | - "*" 154 | AllowMethods: 155 | - POST 156 | AllowOrigins: 157 | - "*" 158 | Description: !Sub "${AWS::StackName} - Webhook API" 159 | Name: webhook 160 | DisableExecuteApiEndpoint: false 161 | 162 | WebhookFunctionLogGroup: 163 | Type: "AWS::Logs::LogGroup" 164 | UpdateReplacePolicy: Delete 165 | DeletionPolicy: Delete 166 | Metadata: 167 | cfn_nag: 168 | rules_to_suppress: 169 | - id: W84 170 | reason: "Ignoring KMS key" 171 | Properties: 172 | LogGroupName: !Sub "/aws/lambda/${WebhookFunction}" 173 | RetentionInDays: 3 174 | Tags: 175 | - Key: "aws-cloudformation:stack-name" 176 | Value: !Ref "AWS::StackName" 177 | - Key: "aws-cloudformation:stack-id" 178 | Value: !Ref "AWS::StackId" 179 | - Key: "aws-cloudformation:logical-id" 180 | Value: WebhookFunctionLogGroup 181 | 182 | WebhookFunctionRole: 183 | Type: "AWS::IAM::Role" 184 | Properties: 185 | AssumeRolePolicyDocument: 186 | Version: "2012-10-17" 187 | Statement: 188 | Effect: Allow 189 | Principal: 190 | Service: !Sub "lambda.${AWS::URLSuffix}" 191 | Action: "sts:AssumeRole" 192 | Description: !Sub "DO NOT DELETE - Used by Lambda. Created by CloudFormation ${AWS::StackId}" 193 | ManagedPolicyArns: 194 | - !Sub "arn:${AWS::Partition}:iam::aws:policy/AWSXRayDaemonWriteAccess" 195 | Tags: 196 | - Key: "aws-cloudformation:stack-name" 197 | Value: !Ref "AWS::StackName" 198 | - Key: "aws-cloudformation:stack-id" 199 | Value: !Ref "AWS::StackId" 200 | - Key: "aws-cloudformation:logical-id" 201 | Value: WebhookFunctionRole 202 | 203 | WebhookFunctionPolicy: 204 | Type: "AWS::IAM::Policy" 205 | Properties: 206 | PolicyName: WebhookFunctionPolicy 207 | PolicyDocument: 208 | Version: "2012-10-17" 209 | Statement: 210 | - Effect: Allow 211 | Action: "s3:PutObject" 212 | Resource: !Sub "${Bucket.Arn}/${BucketPrefix}*" 213 | Condition: 214 | ArnEquals: 215 | "lambda:SourceFunctionArn": !GetAtt WebhookFunction.Arn 216 | - Effect: Allow 217 | Action: 218 | - "kms:DescribeKey" 219 | - "kms:Encrypt" 220 | - "kms:GenerateDataKey" 221 | Resource: !GetAtt EncryptionKey.Arn 222 | - Effect: Allow 223 | Action: 224 | - "dynamodb:GetItem" 225 | - "dynamodb:PutItem" 226 | Resource: !GetAtt Table.Arn 227 | - Effect: Allow 228 | Action: "ssm:GetParameter" 229 | Resource: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${WebhookParameter}" 230 | Roles: 231 | - !Ref WebhookFunctionRole 232 | 233 | CloudWatchLogsPolicy: 234 | Type: "AWS::IAM::Policy" 235 | Properties: 236 | PolicyName: CloudWatchLogs 237 | PolicyDocument: 238 | Version: "2012-10-17" 239 | Statement: 240 | - Effect: Allow 241 | Action: 242 | - "logs:CreateLogStream" 243 | - "logs:PutLogEvents" 244 | Resource: !GetAtt WebhookFunctionLogGroup.Arn 245 | Roles: 246 | - !Ref WebhookFunctionRole 247 | 248 | WebhookFunction: 249 | Type: "AWS::Serverless::Function" 250 | Metadata: 251 | cfn_nag: 252 | rules_to_suppress: 253 | - id: W58 254 | reason: "Ignoring CloudWatch" 255 | - id: W89 256 | reason: "Ignoring VPC" 257 | - id: W92 258 | reason: "Ignoring Reserved Concurrency" 259 | Properties: 260 | CodeUri: src/webhook 261 | Description: !Sub "${AWS::StackName} - Webhook Function" 262 | Events: 263 | HttpApiEvent: 264 | Type: HttpApi 265 | Properties: 266 | ApiId: !Ref HttpApi 267 | Environment: 268 | Variables: 269 | BUCKET_NAME: !Ref Bucket 270 | BUCKET_OWNER_ID: !Ref "AWS::AccountId" 271 | BUCKET_PREFIX: !Ref BucketPrefix 272 | TABLE_NAME: !Ref Table 273 | KMS_KEY_ID: !Ref EncryptionKey 274 | SSM_PARAMETER: !Ref WebhookParameter 275 | Layers: 276 | - !Ref DependencyLayer 277 | Role: !GetAtt WebhookFunctionRole.Arn 278 | 279 | Bucket: 280 | Type: "AWS::S3::Bucket" 281 | Metadata: 282 | cfn_nag: 283 | rules_to_suppress: 284 | - id: W35 285 | reason: "Ignoring access logging" 286 | Properties: 287 | BucketEncryption: 288 | ServerSideEncryptionConfiguration: 289 | - BucketKeyEnabled: true 290 | ServerSideEncryptionByDefault: 291 | KMSMasterKeyID: !GetAtt EncryptionKey.Arn 292 | SSEAlgorithm: "aws:kms" 293 | LifecycleConfiguration: 294 | Rules: 295 | - ExpirationInDays: 3 296 | Id: ExpireAfter3Days 297 | Status: Enabled 298 | - AbortIncompleteMultipartUpload: 299 | DaysAfterInitiation: 1 300 | Id: ExpireNonCurrentAndIncompleteMultipartUpload 301 | NoncurrentVersionExpiration: 302 | NoncurrentDays: 1 303 | Status: Enabled 304 | NotificationConfiguration: 305 | EventBridgeConfiguration: 306 | EventBridgeEnabled: true 307 | OwnershipControls: 308 | Rules: 309 | - ObjectOwnership: BucketOwnerEnforced 310 | PublicAccessBlockConfiguration: 311 | BlockPublicAcls: true 312 | BlockPublicPolicy: true 313 | IgnorePublicAcls: true 314 | RestrictPublicBuckets: true 315 | VersioningConfiguration: 316 | Status: Enabled 317 | 318 | BucketPolicy: 319 | Type: "AWS::S3::BucketPolicy" 320 | Properties: 321 | Bucket: !Ref Bucket 322 | PolicyDocument: 323 | Statement: 324 | - Sid: AllowSSLRequestsOnly 325 | Effect: Deny 326 | Principal: "*" 327 | Action: "s3:*" 328 | Resource: 329 | - !Sub "${Bucket.Arn}/*" 330 | - !GetAtt Bucket.Arn 331 | Condition: 332 | Bool: 333 | "aws:SecureTransport": false 334 | - Sid: DenyUnEncryptedObjectUploads 335 | Effect: Deny 336 | Principal: "*" 337 | Action: "s3:PutObject" 338 | Resource: !Sub "${Bucket.Arn}/*" 339 | Condition: 340 | StringNotEquals: 341 | "s3:x-amz-server-side-encryption": "aws:kms" 342 | 343 | Outputs: 344 | WebhookUrl: 345 | Description: Webhook API URL 346 | Value: !Sub "https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/" 347 | KmsKeyArn: 348 | Description: KMS Key ARN 349 | Value: !GetAtt EncryptionKey.Arn 350 | BucketName: 351 | Description: S3 Bucket Name 352 | Value: !Ref Bucket 353 | BucketArn: 354 | Description: S3 Bucket ARN 355 | Value: !GetAtt Bucket.Arn 356 | -------------------------------------------------------------------------------- /send-webhooks/.gitignore: -------------------------------------------------------------------------------- 1 | samconfig.toml 2 | 3 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 4 | 5 | ### Linux ### 6 | *~ 7 | 8 | # temporary files which can be created if a process still has a handle open of a deleted file 9 | .fuse_hidden* 10 | 11 | # KDE directory preferences 12 | .directory 13 | 14 | # Linux trash folder which might appear on any partition or disk 15 | .Trash-* 16 | 17 | # .nfs files are created when an open file is removed but is still being accessed 18 | .nfs* 19 | 20 | ### OSX ### 21 | *.DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | # Thumbnails 29 | ._* 30 | 31 | # Files that might appear in the root of a volume 32 | .DocumentRevisions-V100 33 | .fseventsd 34 | .Spotlight-V100 35 | .TemporaryItems 36 | .Trashes 37 | .VolumeIcon.icns 38 | .com.apple.timemachine.donotpresent 39 | 40 | # Directories potentially created on remote AFP share 41 | .AppleDB 42 | .AppleDesktop 43 | Network Trash Folder 44 | Temporary Items 45 | .apdisk 46 | 47 | ### PyCharm ### 48 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 49 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 50 | 51 | # User-specific stuff: 52 | .idea/**/workspace.xml 53 | .idea/**/tasks.xml 54 | .idea/dictionaries 55 | 56 | # Sensitive or high-churn files: 57 | .idea/**/dataSources/ 58 | .idea/**/dataSources.ids 59 | .idea/**/dataSources.xml 60 | .idea/**/dataSources.local.xml 61 | .idea/**/sqlDataSources.xml 62 | .idea/**/dynamic.xml 63 | .idea/**/uiDesigner.xml 64 | 65 | # Gradle: 66 | .idea/**/gradle.xml 67 | .idea/**/libraries 68 | 69 | # CMake 70 | cmake-build-debug/ 71 | 72 | # Mongo Explorer plugin: 73 | .idea/**/mongoSettings.xml 74 | 75 | ## File-based project format: 76 | *.iws 77 | 78 | ## Plugin-specific files: 79 | 80 | # IntelliJ 81 | /out/ 82 | 83 | # mpeltonen/sbt-idea plugin 84 | .idea_modules/ 85 | 86 | # JIRA plugin 87 | atlassian-ide-plugin.xml 88 | 89 | # Cursive Clojure plugin 90 | .idea/replstate.xml 91 | 92 | # Ruby plugin and RubyMine 93 | /.rakeTasks 94 | 95 | # Crashlytics plugin (for Android Studio and IntelliJ) 96 | com_crashlytics_export_strings.xml 97 | crashlytics.properties 98 | crashlytics-build.properties 99 | fabric.properties 100 | 101 | ### PyCharm Patch ### 102 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 103 | 104 | # *.iml 105 | # modules.xml 106 | # .idea/misc.xml 107 | # *.ipr 108 | 109 | # Sonarlint plugin 110 | .idea/sonarlint 111 | 112 | ### Python ### 113 | # Byte-compiled / optimized / DLL files 114 | __pycache__/ 115 | *.py[cod] 116 | *$py.class 117 | 118 | # C extensions 119 | *.so 120 | 121 | # Distribution / packaging 122 | .Python 123 | build/ 124 | develop-eggs/ 125 | dist/ 126 | downloads/ 127 | eggs/ 128 | .eggs/ 129 | lib/ 130 | lib64/ 131 | parts/ 132 | sdist/ 133 | var/ 134 | wheels/ 135 | *.egg-info/ 136 | .installed.cfg 137 | *.egg 138 | 139 | # PyInstaller 140 | # Usually these files are written by a python script from a template 141 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 142 | *.manifest 143 | *.spec 144 | 145 | # Installer logs 146 | pip-log.txt 147 | pip-delete-this-directory.txt 148 | 149 | # Unit test / coverage reports 150 | htmlcov/ 151 | .tox/ 152 | .coverage 153 | .coverage.* 154 | .cache 155 | .pytest_cache/ 156 | nosetests.xml 157 | coverage.xml 158 | *.cover 159 | .hypothesis/ 160 | 161 | # Translations 162 | *.mo 163 | *.pot 164 | 165 | # Flask stuff: 166 | instance/ 167 | .webassets-cache 168 | 169 | # Scrapy stuff: 170 | .scrapy 171 | 172 | # Sphinx documentation 173 | docs/_build/ 174 | 175 | # PyBuilder 176 | target/ 177 | 178 | # Jupyter Notebook 179 | .ipynb_checkpoints 180 | 181 | # pyenv 182 | .python-version 183 | 184 | # celery beat schedule file 185 | celerybeat-schedule.* 186 | 187 | # SageMath parsed files 188 | *.sage.py 189 | 190 | # Environments 191 | .env 192 | .venv 193 | env/ 194 | venv/ 195 | ENV/ 196 | env.bak/ 197 | venv.bak/ 198 | 199 | # Spyder project settings 200 | .spyderproject 201 | .spyproject 202 | 203 | # Rope project settings 204 | .ropeproject 205 | 206 | # mkdocs documentation 207 | /site 208 | 209 | # mypy 210 | .mypy_cache/ 211 | 212 | ### VisualStudioCode ### 213 | .vscode/* 214 | !.vscode/settings.json 215 | !.vscode/tasks.json 216 | !.vscode/launch.json 217 | !.vscode/extensions.json 218 | .history 219 | 220 | ### Windows ### 221 | # Windows thumbnail cache files 222 | Thumbs.db 223 | ehthumbs.db 224 | ehthumbs_vista.db 225 | 226 | # Folder config file 227 | Desktop.ini 228 | 229 | # Recycle Bin used on file shares 230 | $RECYCLE.BIN/ 231 | 232 | # Windows Installer files 233 | *.cab 234 | *.msi 235 | *.msm 236 | *.msp 237 | 238 | # Windows shortcuts 239 | *.lnk 240 | 241 | # Build folder 242 | 243 | */build/* 244 | 245 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 246 | -------------------------------------------------------------------------------- /send-webhooks/README.md: -------------------------------------------------------------------------------- 1 | # Sending Webhooks on AWS 2 | 3 | An example event-driven application which sends webhooks using [Amazon EventBridge Pipes](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes.html). 4 | 5 | ## How it works? 6 | 7 | The application takes the [change data capture event for DynamoDB streams](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html) and uses [EventBridge Pipes](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes.html) and [API destinations](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-api-destinations.html) to send the event to an external endpoint. 8 | 9 | ![Reference Architecture](images/architecture-send-webhooks.png) 10 | 11 | ## Pre-Requisites 12 | 13 | * [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) 14 | * Python 3.10 15 | * Optional: [evb-cli](https://github.com/mhlabs/evb-cli) for generating EventBridge patterns 16 | * Optional: [eventbridge-transformer](https://eventbridge-transformer.vercel.app/) 17 | 18 | ## Setup 19 | 20 | 1. Deploy the application using AWS SAM and follow the instructions. 21 | 22 | ``` 23 | sam deploy --guided 24 | ``` 25 | 26 | 2. Once the application is deployed, you can test a webhook delivery by modifying the DynamoDB table. For example: 27 | 28 | ``` 29 | paymentId=$(date -u +"%Y%m%dT%H%M%S") 30 | aws dynamodb put-item \ 31 | --table-name PaymentStatusEvents \ 32 | --item '{ 33 | "paymentId": {"S": "'$paymentId'"}, 34 | "status": {"S": "Paid"} 35 | }' 36 | ``` 37 | 38 | 3. The webhook will subsequently be delivered to the endpoint specified. You can use tools such as [webhook.site](https://webhook.site/) for prototyping such as in the code example: [https://webhook.site/37e1931b-30c9-4d31-8336-8ec57b8be177](https://webhook.site/37e1931b-30c9-4d31-8336-8ec57b8be177) 39 | 40 | ## Clean up 41 | 42 | To avoid unnecessary costs, clean up after using the solution. 43 | 44 | ``` 45 | sam delete 46 | ``` 47 | 48 | ### Notes 49 | 50 | * For illustrative purposes only, we use `API_KEY` (instead of `OAUTH_CLIENT_CREDENTIALS`) as the `AuthorizationType` for the API Destinations [Connection](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-events-connection.html). If you would like to use OAuth, you will need to specify an endpoint with OAuth. 51 | 52 | * The webhook subscription management application is currently **not** included in this repository. However, if you're interested in exploring a solution, please feel free to raise a Github issue. 53 | 54 | * This repository is for illustrative purposes only. In production, ensure that you store any sensitive credentials securely, such as using AWS Secrets Manager. 55 | -------------------------------------------------------------------------------- /send-webhooks/images/architecture-send-webhooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/webhooks/ad49e65587abc7ca88232a3aa37478d4c79ba23e/send-webhooks/images/architecture-send-webhooks.png -------------------------------------------------------------------------------- /send-webhooks/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: Sample architecture with DynamoDB, EventBridge Pipes, and API Destinations 4 | 5 | Parameters: 6 | WebhookUrl: 7 | Type: String 8 | Default: https://webhook.site/37e1931b-30c9-4d31-8336-8ec57b8be177 # Test using webhook.site utility 9 | WebhookApiKey: 10 | Type: String 11 | Default: 'test-api-key' # For illustrative purposes only. In production, this should be secret! 12 | 13 | Globals: # https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-template-anatomy-globals.html 14 | Function: 15 | Timeout: 5 16 | MemorySize: 128 17 | Runtime: python3.10 18 | 19 | Resources: 20 | # DynamoDB table as event source 21 | DynamoDBTable: 22 | Type: AWS::DynamoDB::Table 23 | Properties: 24 | TableName: PaymentStatusEvents 25 | BillingMode: PAY_PER_REQUEST 26 | AttributeDefinitions: 27 | - AttributeName: paymentId 28 | AttributeType: S 29 | KeySchema: 30 | - AttributeName: paymentId 31 | KeyType: HASH 32 | StreamSpecification: 33 | StreamViewType: NEW_AND_OLD_IMAGES 34 | PointInTimeRecoverySpecification: 35 | PointInTimeRecoveryEnabled: true 36 | SSEEnabled: false # Use an AWS-owned key for server-side encryption 37 | Metadata: 38 | cfn_nag: 39 | rules_to_suppress: 40 | - id: W74 41 | reason: "Ignoring KMS key" 42 | - id: W28 43 | reason: "Explicit name" 44 | 45 | # DLQ for DDB Stream (Source) 46 | SourceDLQ: 47 | Type: AWS::SQS::Queue 48 | Properties: 49 | SqsManagedSseEnabled: true 50 | Metadata: 51 | cfn_nag: 52 | rules_to_suppress: 53 | - id: W48 54 | reason: "Ignoring KMS key" 55 | 56 | # IAM Role for Pipe 57 | PipeRole: 58 | Type: AWS::IAM::Role 59 | Properties: 60 | AssumeRolePolicyDocument: 61 | Version: 2012-10-17 62 | Statement: 63 | - Effect: Allow 64 | Principal: 65 | Service: 66 | - pipes.amazonaws.com 67 | Action: 68 | - sts:AssumeRole 69 | Policies: 70 | - PolicyName: SourcePolicy 71 | PolicyDocument: 72 | Version: 2012-10-17 73 | Statement: 74 | - Effect: Allow 75 | Action: 76 | - "dynamodb:DescribeStream" 77 | - "dynamodb:GetRecords" 78 | - "dynamodb:GetShardIterator" 79 | - "dynamodb:ListStreams" 80 | Resource: !GetAtt DynamoDBTable.StreamArn 81 | - PolicyName: SourceDLQPolicy 82 | PolicyDocument: 83 | Version: 2012-10-17 84 | Statement: 85 | - Effect: Allow 86 | Action: 87 | - 'sqs:GetQueueAttributes' 88 | - 'sqs:SendMessage' 89 | Resource: !GetAtt SourceDLQ.Arn 90 | - PolicyName: TargetPolicy 91 | PolicyDocument: 92 | Version: 2012-10-17 93 | Statement: 94 | - Effect: Allow 95 | Action: 96 | - 'events:InvokeApiDestination' 97 | Resource: !GetAtt ApiDestinationWebhookConsumer.Arn 98 | 99 | # EventBridge Pipe 100 | Pipe: 101 | Type: AWS::Pipes::Pipe 102 | DependsOn: 103 | - SourceDLQ 104 | - ApiDestinationWebhookConsumer 105 | - PipeRole 106 | Properties: 107 | Name: ddb-to-api-destinations 108 | Description: "Pipe to connect DDB stream to EventBridge API Destinations" 109 | RoleArn: !GetAtt PipeRole.Arn 110 | Source: !GetAtt DynamoDBTable.StreamArn 111 | SourceParameters: 112 | DynamoDBStreamParameters: 113 | StartingPosition: LATEST 114 | BatchSize: 1 115 | DeadLetterConfig: 116 | Arn: !GetAtt SourceDLQ.Arn 117 | FilterCriteria: 118 | Filters: 119 | - Pattern: '{"eventName" : ["INSERT", "MODIFY"] }' 120 | Target: !GetAtt ApiDestinationWebhookConsumer.Arn 121 | TargetParameters: 122 | InputTemplate: | 123 | { 124 | "specversion": "1.0", 125 | "id": <$.eventID>, 126 | "type": "payment-status", 127 | "source": "<$.eventSourceARN>", 128 | "region": "<$.awsRegion>", 129 | "data": { 130 | "paymentId": <$.dynamodb.NewImage.paymentId.S>, 131 | "status": <$.dynamodb.NewImage.status.S> 132 | } 133 | } 134 | 135 | 136 | WebhookConnection: 137 | Type: AWS::Events::Connection 138 | Properties: 139 | Description: 'Connection with API Key' 140 | AuthorizationType: API_KEY 141 | AuthParameters: 142 | ApiKeyAuthParameters: 143 | ApiKeyName: 'x-api-key' 144 | ApiKeyValue: !Ref WebhookApiKey 145 | 146 | ApiDestinationWebhookConsumer: 147 | Type: AWS::Events::ApiDestination 148 | DependsOn: 149 | - WebhookConnection 150 | Properties: 151 | Name: 'WebhookConsumerTest' 152 | ConnectionArn: !GetAtt WebhookConnection.Arn 153 | InvocationEndpoint: !Ref WebhookUrl 154 | HttpMethod: POST 155 | InvocationRateLimitPerSecond: 10 156 | 157 | Outputs: 158 | DynamoDBTableArn: 159 | Description: The ARN of the DynamoDB table for payment status events. 160 | Value: !GetAtt DynamoDBTable.Arn 161 | ApiDestinationWebhookConsumerArn: 162 | Value: !GetAtt ApiDestinationWebhookConsumer.Arn --------------------------------------------------------------------------------