Connect Your API to a CRM via Webhooks
Push validated leads from your API into HubSpot CRM in real time.
Introduction
30 Second Summary
When a potential customer fills out a form on a company's website, that information needs to reach the sales team instantly. Behind the scenes, this happens through automated integrations between systems.
In this project, you will build a webhook integration that connects your Lead Intake API to HubSpot CRM, so every validated lead automatically appears as a new contact in the sales team's dashboard.
What You'll Build
A CRM-integrated Lead Intake API that stores leads in DynamoDB and fires an outbound HTTP POST to HubSpot's Contacts API, creating a new contact in real time.
By the end of this project, you'll have:
- A redeployed Lead Intake API with a DynamoDB table, Lambda function, API Gateway endpoint, and API key.
- A HubSpot free CRM account with a private app configured for API access.
- An outbound webhook integration that maps lead fields to HubSpot contact properties and syncs them in real time.
- A Postman-tested end-to-end flow proving data arrives in both DynamoDB and HubSpot simultaneously.
- Secret Mission: Add sync status tracking so your DynamoDB records show whether each lead successfully reached HubSpot.
Are there any prerequisites?
This is Project 3 in the Solutions Engineer portfolio series. You should be familiar with the AWS Console, Lambda functions, API Gateway, and DynamoDB from Projects 1 and 2.
You will also need an AWS account with IAM admin access and Postman installed on your machine.
Redeploy Your Lead Intake API
Before you can connect your API to a CRM, you need the base infrastructure in place. Your Lead Intake API from Projects 1 and 2 accepts lead submissions, validates them, and stores them in a database.
In this step, you will rebuild that entire API from scratch. Since you have done this before, this step moves quickly through the setup so you can focus on the new integration work in later steps.
In this step, get ready to:
- Create the DynamoDB Leads table.
- Create and configure the LeadIntakeFunction Lambda.
- Set up the API Gateway REST API with API key protection.
Create the DynamoDB Leads table
Your leads need a place to live. DynamoDB stores each lead as an item, keyed by email address and submission timestamp.
- Sign in to the AWS Management Console.
- Search for DynamoDB in the top search bar and open the service.
- Click Create table.
- For Table name, enter Leads.
- For Partition key, enter email and select String as the type.
- For Sort key, enter submitted_at and select String as the type.
- Under Table settings, select Customize settings.
- For Read/write capacity settings, select On-demand.
- Leave all other settings at their defaults and click Create table.
Wait until the table status changes to Active. This takes about 10 seconds.
Why on-demand capacity?
On-demand mode means DynamoDB automatically scales to handle your traffic without you needing to configure read/write capacity units. For a learning project with low and unpredictable traffic, this is the simplest and most cost-effective option.
Table creation failed?
Make sure email and submitted_at are both spelled exactly as shown, with underscores and lowercase. DynamoDB key names are case-sensitive.
If you see a permissions error, confirm your IAM user has admin access. Why can't I create a DynamoDB table?
Create the LeadIntakeFunction Lambda
The Lambda function is the brain of your API. It receives requests, validates the data, and writes leads to DynamoDB.
- Search for Lambda in the top search bar and open the service.
- Click Create function.
- Select Author from scratch.
- For Function name, enter LeadIntakeFunction.
- For Runtime, select Python 3.13.
- Leave all other settings at their defaults and click Create function.
Now attach the DynamoDB permission policy to the function's execution role.
- On the function page, click the Configuration tab.
- Click Permissions in the left sidebar.
- Under Execution role, click the role name link. This opens the IAM console in a new tab.
- Click Add permissions, then Attach policies.
- Search for AmazonDynamoDBFullAccess and select the checkbox next to it.
- Click Add permissions.
- Close the IAM tab and return to the Lambda console tab.
Now paste the handler code that validates and stores leads.
- Click the Code tab on your function page.
- In the code editor, select all the existing code and delete it.
- Paste the following code into the editor:
import json
import boto3
from datetime import datetime, timezone
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("Leads")
def lambda_handler(event, context):
try:
body = json.loads(event.get("body") or "{}")
except json.JSONDecodeError:
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Invalid JSON in request body"}),
}
errors = []
required_fields = ["name", "email", "phone", "service_interest"]
for field in required_fields:
if not body.get(field):
errors.append(f"Missing required field: {field}")
if errors:
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"errors": errors}),
}
email = body["email"].strip().lower()
submitted_at = datetime.now(timezone.utc).isoformat()
item = {
"email": email,
"submitted_at": submitted_at,
"name": body["name"].strip(),
"phone": body["phone"].strip(),
"service_interest": body["service_interest"].strip(),
}
try:
table.put_item(Item=item)
except Exception as e:
return {
"statusCode": 500,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Failed to save lead", "detail": str(e)}),
}
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"message": "Lead received successfully",
"lead_id": f"{email}#{submitted_at}",
}),
}
What does this code do?
- Parses the incoming JSON request body.
- Validates that four required fields are present: name, email, phone, and service_interest.
- Normalizes the email to lowercase and generates a UTC timestamp.
- Writes the lead to the DynamoDB Leads table using put_item.
- Returns a success response with a composite lead_id (email + timestamp).
- Click Deploy.
You should see a green banner at the top of the console confirming the function was successfully updated.
Deploy button greyed out?
If the Deploy button is greyed out, make sure you pasted code into the editor first. The button only activates when the code has changed from the last deployed version.
Help me deploy my Lambda function code.
Create the API Gateway REST API
API Gateway exposes your Lambda function as a public HTTP endpoint. You will create a REST API with a /leads resource, protect it with an API key, and deploy it to a dev stage.
- Search for API Gateway in the top search bar and open the service.
- Click Create API.
- Find REST API (not the Private option) and click Build.
- For API name, enter LeadIntakeAPI.
- Leave the other settings at their defaults and click Create API.
Now create the /leads resource and POST method.
- In the left sidebar, click Resources.
- Click Create resource.
- For Resource name, enter leads. The resource path auto-fills to /leads.
- Click Create resource.
- With /leads selected, click Create method.
- For Method type, select POST.
- For Integration type, select Lambda Function.
- Enable Lambda proxy integration.
- For Lambda function, search for and select LeadIntakeFunction.
- Click Create method.
Now set the method to require an API key.
- Click the POST method under /leads.
- On the Method request tab, under Method request settings, click Edit.
- Set API key required to true.
- Click Save.
Deploy the API to the dev stage.
- Click Deploy API (top right area of the Resources page).
- For Stage, select *New Stage* and enter dev as the stage name.
- Click Deploy.
- Copy the Invoke URL shown at the top of the stage page. You will need this later for testing.
Now create an API key and usage plan to protect the endpoint.
- In the left sidebar, click API keys.
- Click Create API key.
- For Name, enter LeadIntakeKey.
- Leave API key set to Auto generate.
- Click Save.
- Copy the API key value by clicking Show next to the key. Save this somewhere safe for testing.
- In the left sidebar, click Usage plans.
- Click Create usage plan.
- For Name, enter LeadIntakePlan.
- For Rate, enter 5 requests per second.
- For Burst, enter 10.
- For Requests (quota), enter 1000 per Month.
- Click Create usage plan.
Associate your API stage and API key with the usage plan.
- On the usage plan page, find the Associated API stages section and click Add API stage.
- Select LeadIntakeAPI and the dev stage.
- Click the checkmark to confirm.
- Switch to the Associated API keys tab and click Add API key.
- Select Add existing key and choose LeadIntakeKey from the dropdown.
- Click Add API key.
What is a usage plan?
A usage plan connects your API key to your deployed API stage and enforces rate limits. Without a usage plan, the API key has no effect.
The plan you created allows 5 requests per second, bursts up to 10, and caps total usage at 1000 requests per month.
Getting a 403 Forbidden when testing?
The most common cause is a missing link between the API key, usage plan, and stage. Confirm all three are connected: the usage plan must have the dev stage associated AND the API key attached.
Also verify the POST method has API key required set to true and that you redeployed after making that change.
Why am I getting 403 Forbidden from my API Gateway endpoint?
With all resources in place, send a test request to confirm everything works end to end.
- Open Terminal on your Mac and send a test request by running this command:
curl -X POST [[INVOKE_URL="https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com"]]/dev/leads \
-H "Content-Type: application/json" \
-H "x-api-key: your-api-key-here" \
-d '{"name": "Jane Smith", "email": "jane@example.com", "phone": "555-0123", "service_interest": "Cloud Migration"}'
What do I replace in this command?
- Replace the invoke URL with the one you copied from the API Gateway stage page.
- Replace your-api-key-here with the API key value you copied earlier.
- The x-api-key header is how API Gateway identifies which key is making the request.
You should see a JSON response with status 200 containing the message Lead received successfully and a lead_id.
- Go back to the DynamoDB console, select the Leads table, and click Explore table items.
- Confirm the lead from Jane Smith appears with the correct email, phone, and service interest fields.
Seeing an Internal Server Error?
A 500 error usually means the Lambda function cannot write to DynamoDB. Confirm that AmazonDynamoDBFullAccess is attached to the function's execution role (check under Configuration then Permissions in the Lambda console).
Also verify the table is named exactly Leads (capital L). The table name in the code must match the table you created.
Help me fix my Lambda 500 error when writing to DynamoDB.
✔️ Awesome, I've got everything!
Great. Make sure you saved your API key and invoke URL somewhere accessible. You will need both again in later steps.
ⓧ I'd like to double check the full code
Here is the full lambda_function.py that should be deployed in your Lambda function:
import json
import boto3
from datetime import datetime, timezone
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("Leads")
def lambda_handler(event, context):
try:
body = json.loads(event.get("body") or "{}")
except json.JSONDecodeError:
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Invalid JSON in request body"}),
}
errors = []
required_fields = ["name", "email", "phone", "service_interest"]
for field in required_fields:
if not body.get(field):
errors.append(f"Missing required field: {field}")
if errors:
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"errors": errors}),
}
email = body["email"].strip().lower()
submitted_at = datetime.now(timezone.utc).isoformat()
item = {
"email": email,
"submitted_at": submitted_at,
"name": body["name"].strip(),
"phone": body["phone"].strip(),
"service_interest": body["service_interest"].strip(),
}
try:
table.put_item(Item=item)
except Exception as e:
return {
"statusCode": 500,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Failed to save lead", "detail": str(e)}),
}
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"message": "Lead received successfully",
"lead_id": f"{email}#{submitted_at}",
}),
}
Your Lead Intake API is live and working. Next up, you will connect it to a real CRM so that every valid lead automatically creates a contact in HubSpot.
Set Up HubSpot Free CRM
Your Lead Intake API is live and storing validated leads in DynamoDB. Now it is time to connect it to a real CRM so leads flow into the same system a sales team would actually use.
HubSpot offers a free CRM with a developer-friendly API. You will create a private app inside HubSpot that gives your Lambda function permission to create contacts on your behalf.
In this step, get ready to:
- Sign up for a free HubSpot CRM account.
- Create a private app that grants your Lambda function API access.
- Store the access token securely as a Lambda environment variable.
Create a free HubSpot account
- Open your browser and navigate to hubspot.com.
- Click Get started free.
- Enter your email address and click Next.
- Check your inbox for a verification code, enter it, and create a password.
What about the onboarding questions?
HubSpot will ask a few questions about your business (industry, role, company name, company size). Enter any values that make sense for you. These answers tailor your dashboard experience but do not affect API access.
After the initial profile questions, HubSpot may ask about your goals and current tools. You can skip these to reach the dashboard faster.
- Complete the onboarding prompts (or skip the optional ones) until you reach the HubSpot dashboard.
No credit card is required. Your free account stays active indefinitely.
What is a CRM?
A CRM (Customer Relationship Management) system is where sales teams track contacts, deals, and communications. When a Solutions Engineer builds an integration, the CRM is almost always one end of the data flow.
Create a legacy private app
A private app generates a static access token that your Lambda function will use to authenticate with the HubSpot API. The token does not expire, which makes it ideal for a learning project.
- In the HubSpot left sidebar, click Development.
- Click Legacy apps in the left sidebar.
- Click Create legacy app.
- Select Private.
- Enter Lead Intake Integration as the app name.
- Click the Scopes tab.
- Search for and add the scope crm.objects.contacts.write. This grants permission to create contacts via the API.
- Click Create app.
Your private app is now active. Next, you need to copy the access token it generated.
- Click the Auth tab.
- Click Show token to reveal your access token.
- Click Copy to save the token to your clipboard. Keep this value private.
Why a private app instead of OAuth?
HubSpot offers two auth approaches. Public apps use OAuth with token refresh flows. Private apps generate a static Bearer token that never expires.
For a server-to-server integration like this one, a private app token is simpler. There is no user login flow and no token expiry to handle.
Don't see Legacy apps in the sidebar?
Make sure you are clicking Development first. The Legacy apps option appears as a sub-item under the Development section. If your sidebar is collapsed, expand it by clicking the hamburger menu at the top left.
Still stuck? Help me find the Legacy apps page in HubSpot.
Store the access token as a Lambda environment variable
Your Lambda function needs the token to authenticate with HubSpot. Storing it as an environment variable keeps the secret out of your function code.
- Switch back to the AWS Lambda console and open your LeadIntakeFunction function.
- Click the Configuration tab.
- Click Environment variables in the left sidebar.
- Click Edit.
- Click Add environment variable.
- In the Key field, enter HUBSPOT_ACCESS_TOKEN.
- In the Value field, paste the access token you copied from HubSpot.
- Click Save.
You should see HUBSPOT_ACCESS_TOKEN listed under Environment variables with your token value.
Why an environment variable instead of hardcoding?
Hardcoding secrets in your function code is a security risk. Anyone who views the code sees the token. Environment variables separate configuration from code so you can rotate tokens without redeploying.
Environment variable not saving?
Make sure the key is exactly HUBSPOT_ACCESS_TOKEN with no spaces or trailing characters. Lambda environment variable keys are case-sensitive.
Also confirm you clicked Save at the bottom of the edit panel (not just Add environment variable).
Need more help? Help me add an environment variable to my Lambda function.
Your HubSpot CRM is ready and your Lambda function has the credentials it needs. Next up, you will update the Lambda code to actually call the HubSpot API and create contacts in real time.
Add the CRM Webhook to Your Lambda
Your HubSpot CRM is ready to accept new contacts through its API. Now it is time to connect the two systems.
In this step, you will update your Lambda function so that every validated lead is not only stored in DynamoDB but also pushed to HubSpot as a new contact in real time. This is an outbound webhook pattern. Your function makes an HTTP POST to an external API after completing its own internal work.
In this step, get ready to:
- Replace your Lambda code with the CRM integration version.
- Deploy and test the function end-to-end with a Lambda test event.
Replace your Lambda function code
The updated code adds a new function called sync_to_hubspot that sends each validated lead to the HubSpot Contacts API. It uses Python's built-in urllib.request module to make the HTTP POST request.
Why urllib.request instead of the requests library?
The AWS Lambda Python runtime does not include the third-party requests library. Only boto3 and the Python standard library are pre-installed.
Using urllib.request avoids the need for Lambda Layers or packaging dependencies. It keeps the project simple while teaching you what higher-level HTTP libraries abstract away.
- Return to your LeadIntakeFunction in the Lambda console.
- Click the Code tab to open the inline code editor.
- Select all the existing code in lambda_function.py and delete it.
- Paste the following updated code:
import json
import boto3
import os
from datetime import datetime, timezone
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("Leads")
HUBSPOT_ACCESS_TOKEN = os.environ.get("HUBSPOT_ACCESS_TOKEN", "")
HUBSPOT_CONTACTS_URL = "https://api.hubapi.com/crm/v3/objects/contacts"
def sync_to_hubspot(lead_data):
"""Send the validated lead to HubSpot CRM as a new contact."""
name_parts = lead_data["name"].split()
firstname = name_parts[0] if name_parts else ""
lastname = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
hubspot_payload = json.dumps({
"properties": {
"email": lead_data["email"],
"firstname": firstname,
"lastname": lastname,
"phone": lead_data["phone"],
}
}).encode("utf-8")
request = Request(
HUBSPOT_CONTACTS_URL,
data=hubspot_payload,
headers={
"Authorization": f"Bearer {HUBSPOT_ACCESS_TOKEN}",
"Content-Type": "application/json",
},
)
try:
response = urlopen(request)
response_body = json.loads(response.read().decode("utf-8"))
return {"success": True, "hubspot_id": response_body.get("id")}
except HTTPError as e:
error_body = e.read().decode("utf-8")
return {"success": False, "error": error_body}
except URLError as e:
return {"success": False, "error": str(e.reason)}
def lambda_handler(event, context):
try:
body = json.loads(event.get("body") or "{}")
except json.JSONDecodeError:
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Invalid JSON in request body"}),
}
errors = []
required_fields = ["name", "email", "phone", "service_interest"]
for field in required_fields:
if not body.get(field):
errors.append(f"Missing required field: {field}")
if errors:
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"errors": errors}),
}
email = body["email"].strip().lower()
submitted_at = datetime.now(timezone.utc).isoformat()
item = {
"email": email,
"submitted_at": submitted_at,
"name": body["name"].strip(),
"phone": body["phone"].strip(),
"service_interest": body["service_interest"].strip(),
}
try:
table.put_item(Item=item)
except Exception as e:
return {
"statusCode": 500,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Failed to save lead", "detail": str(e)}),
}
hubspot_result = sync_to_hubspot(item)
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"message": "Lead received and synced to CRM",
"lead_id": f"{email}#{submitted_at}",
"crm_sync": hubspot_result,
}),
}
What does this code do?
The new code adds two major pieces on top of the original handler:
- HUBSPOT_ACCESS_TOKEN and HUBSPOT_CONTACTS_URL are loaded as module-level constants. The token comes from the environment variable you set in Step 2.
- sync_to_hubspot splits the lead's full name into firstname and lastname, builds a JSON payload matching HubSpot's contact properties format, and sends an HTTP POST with the Bearer token.
- The lambda_handler now calls sync_to_hubspot(item) after storing the lead in DynamoDB, and includes the crm_sync result in the API response.
- Error handling catches both HTTPError (bad status codes from HubSpot) and URLError (network failures), returning structured error info instead of crashing.
✔️ Awesome, I've got everything!
Great. Make sure the code is pasted cleanly with no leftover lines from the old version.
ⓧ I'd like to double check the full code
Here is the complete lambda_function.py you should have after pasting:
import json
import boto3
import os
from datetime import datetime, timezone
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("Leads")
HUBSPOT_ACCESS_TOKEN = os.environ.get("HUBSPOT_ACCESS_TOKEN", "")
HUBSPOT_CONTACTS_URL = "https://api.hubapi.com/crm/v3/objects/contacts"
def sync_to_hubspot(lead_data):
"""Send the validated lead to HubSpot CRM as a new contact."""
name_parts = lead_data["name"].split()
firstname = name_parts[0] if name_parts else ""
lastname = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
hubspot_payload = json.dumps({
"properties": {
"email": lead_data["email"],
"firstname": firstname,
"lastname": lastname,
"phone": lead_data["phone"],
}
}).encode("utf-8")
request = Request(
HUBSPOT_CONTACTS_URL,
data=hubspot_payload,
headers={
"Authorization": f"Bearer {HUBSPOT_ACCESS_TOKEN}",
"Content-Type": "application/json",
},
)
try:
response = urlopen(request)
response_body = json.loads(response.read().decode("utf-8"))
return {"success": True, "hubspot_id": response_body.get("id")}
except HTTPError as e:
error_body = e.read().decode("utf-8")
return {"success": False, "error": error_body}
except URLError as e:
return {"success": False, "error": str(e.reason)}
def lambda_handler(event, context):
try:
body = json.loads(event.get("body") or "{}")
except json.JSONDecodeError:
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Invalid JSON in request body"}),
}
errors = []
required_fields = ["name", "email", "phone", "service_interest"]
for field in required_fields:
if not body.get(field):
errors.append(f"Missing required field: {field}")
if errors:
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"errors": errors}),
}
email = body["email"].strip().lower()
submitted_at = datetime.now(timezone.utc).isoformat()
item = {
"email": email,
"submitted_at": submitted_at,
"name": body["name"].strip(),
"phone": body["phone"].strip(),
"service_interest": body["service_interest"].strip(),
}
try:
table.put_item(Item=item)
except Exception as e:
return {
"statusCode": 500,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Failed to save lead", "detail": str(e)}),
}
hubspot_result = sync_to_hubspot(item)
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"message": "Lead received and synced to CRM",
"lead_id": f"{email}#{submitted_at}",
"crm_sync": hubspot_result,
}),
}
Deploy and test with a Lambda test event
Before testing through API Gateway, you can invoke the function directly in the Lambda console to verify the HubSpot integration works.
- Click Deploy to save your updated function code.
- Click the Test tab.
- Under Test event, select Create new event.
- Enter ValidLeadTest as the event name.
- Replace the default event JSON with the following test payload:
{
"body": "{\"name\": \"Jane Smith\", \"email\": \"jane.smith@example.com\", \"phone\": \"+1234567890\", \"service_interest\": \"Cloud Migration\"}"
}
Why does the body look like that?
Your function uses Lambda proxy integration with API Gateway. In production, API Gateway wraps the HTTP request body as a JSON string inside the body field of the event object. The test event mimics this format so your function parses it the same way.
- Click Save changes to save the test event.
- Click Test.
Under Execution result, expand Details. You should see a 200 status code with a response body containing crm_sync showing success: true and a numeric hubspot_id.
Seeing an error in the execution result?
- If you see "success": false with an authorization error, check that your HUBSPOT_ACCESS_TOKEN environment variable is set correctly under Configuration > Environment variables. Make sure you copied the full token with no extra spaces.
- If you see a timeout error, your Lambda function's default 3-second timeout may be too short for the external HTTP call. Go to Configuration > General configuration > Edit and increase the timeout to 10 seconds.
- If you see a syntax error, make sure no leftover lines from the old code remain below your pasted code.
help me debug my Lambda function's HubSpot integration error
Your Lambda function now stores leads locally AND pushes them to HubSpot in real time. Next up, you will test the full end-to-end flow through Postman and verify data lands in both systems.
Test the End-to-End Flow
Your Lambda function successfully synced a test lead to HubSpot in the previous step. That test ran inside the Lambda console, bypassing API Gateway entirely.
Now it is time to prove the entire pipeline works from the outside in. You will send requests through Postman to your public API endpoint, exactly how a real frontend or third-party service would call your API.
In this step, get ready to:
- Submit a valid lead through Postman and confirm it lands in both DynamoDB and HubSpot.
- Test duplicate and invalid submissions to verify error handling works correctly.
Send a valid lead through Postman
Postman lets you craft HTTP requests with full control over headers, body, and method. You will build a POST request that mimics what a real application would send to your Lead Intake API.
- Open Postman on your machine.
- Click the + button to create a new request tab.
- Change the method dropdown from GET to POST.
- Paste your API Gateway invoke URL into the request URL field. This is the URL from your LeadIntakeAPI deployed to the dev stage, ending in /dev/leads.
Find your invoke URL
In the API Gateway console, click LeadIntakeAPI, then Stages, then dev. The invoke URL is displayed at the top of the stage editor.
- Click the Headers tab in Postman.
- Add a new header with key x-api-key and paste your API key as the value.
Find your API key
In the API Gateway console, click API Keys in the left sidebar. Click your key name, then click Show next to the API key value to reveal it.
- Click the Body tab in Postman.
- Select raw and change the dropdown on the right from Text to JSON.
- Paste the following JSON body into the text area:
{
"name": "Jane Cooper",
"email": "jane.cooper@example.com",
"phone": "+1-555-0142",
"service_interest": "Cloud Migration"
}
- Click Send.
You should see a 200 OK response at the bottom of Postman with a JSON body containing "message": "Lead received and synced to CRM" and a crm_sync object showing success: true and a numeric hubspot_id.
Seeing a 403 Forbidden?
Double-check that you added the x-api-key header (not api-key or apikey). The header name must be exactly x-api-key.
Also confirm your URL ends with /dev/leads and not just /dev. A missing resource path returns 403 on API Gateway.
Help me troubleshoot my 403 Forbidden error from API Gateway.
Verify the lead in both systems
A successful response from your API is a good sign, but you need to confirm the data actually arrived in both DynamoDB and HubSpot.
- In the AWS Console, navigate to the DynamoDB service.
- Click Tables in the left sidebar, then click Leads.
- Click Explore table items.
- Confirm a record exists with email set to jane.cooper@example.com along with the name, phone, and service_interest fields you submitted.
Now check HubSpot.
- Switch to your HubSpot account in the browser.
- Navigate to CRM > Contacts.
- Look for a contact with the first name Jane and last name Cooper.
- Click the contact to open the record. Confirm the email, firstname, lastname, and phone properties all match what you submitted.
What about service_interest?
You will not see service_interest in HubSpot because there is no matching default contact property for it. The field stays in DynamoDB only.
This is a realistic constraint in integrations. Not every field maps neatly between systems.
Contact not showing in HubSpot?
Check that your Postman response included crm_sync.success: true. If it shows success: false, read the error field for details.
If the response was successful but the contact is not visible, try refreshing the HubSpot Contacts page. New contacts can take a few seconds to appear in the list.
Help me figure out why my contact isn't appearing in HubSpot CRM.
Test error scenarios
Real-world APIs receive bad data all the time. You need to confirm your integration handles duplicates and invalid submissions gracefully.
- In Postman, click Send again with the same JSON body (the jane.cooper@example.com lead).
You should see a 200 OK response, but this time crm_sync shows success: false. The error field contains HubSpot's 409 Conflict response because a contact with that email already exists.
Why does the API still return 200?
Your Lambda stores the lead in DynamoDB first, then attempts the HubSpot sync. The lead was saved successfully, so the overall operation succeeded.
The crm_sync object in the response tells the caller whether the CRM sync specifically worked or failed. This pattern lets the client decide how to handle partial failures.
Now test an invalid submission.
- Replace the JSON body in Postman with the following incomplete payload:
{
"name": "Test User"
}
- Click Send.
You should see a 400 Bad Request response with an errors array listing the missing fields: email, phone, and service_interest.
Why didn't this reach HubSpot?
Your Lambda validates required fields before calling sync_to_hubspot. If validation fails, the function returns a 400 immediately. HubSpot is never contacted.
This protects your external integration from wasting API calls on data you already know is bad.
Getting a 200 instead of 400 for invalid data?
Make sure you replaced the entire body with the incomplete payload above. If Postman still has the old body, it will submit a valid lead.
Also confirm the Body tab is set to raw and JSON. If it is set to none, no body is sent and the Lambda returns a different error.
Help me troubleshoot why my invalid lead test isn't returning a 400 error.
Secret mission
Add Sync Status Tracking
Your API returns whether HubSpot sync succeeded or failed, but it does not record that outcome anywhere. If you needed to retry failed syncs later, you would have no way to identify which leads never made it to the CRM. In this secret mission, you will add persistent sync status tracking to your DynamoDB records.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. All resources in this project are serverless and cost nothing while idle within AWS Free Tier and HubSpot Free tier limits.
Resources you used:
- DynamoDB table Leads (serverless, no cost while idle within free tier).
- Lambda function LeadIntakeFunction (serverless, no cost while idle).
- API Gateway REST API LeadIntakeAPI deployed to dev stage (serverless, no cost while idle).
- HubSpot private app Lead Intake Integration (free, no cost).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend this integration.
- All four resources are serverless. They only incur charges when invoked, and usage stays well within AWS Free Tier and HubSpot Free tier limits for this project.
- Your HubSpot free CRM account remains active indefinitely with no subscription fees.
Pause - I'll come back to this later
No action needed. Serverless resources do not run continuously, so there is nothing to shut down or pause.
- Lambda, API Gateway, and DynamoDB only consume resources when a request hits your endpoint.
- Your API key remains active. If you want to prevent any accidental calls, you can disable the API key in the API Gateway console without deleting it.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to fully clean up.
- Delete the DynamoDB table: in the AWS Console, open DynamoDB, select Tables, select the Leads table, click Delete, confirm by typing delete, then click Delete table.
- Delete the Lambda function: open Lambda, select Functions, select LeadIntakeFunction, click Actions, then Delete. Confirm the deletion.
- Delete the API Gateway REST API: open API Gateway, select LeadIntakeAPI, click Actions, then Delete API. Confirm the deletion.
- Delete the HubSpot private app: in your HubSpot account, navigate to Development. In the left sidebar menu, click Legacy apps. Click Lead Intake Integration. At the top of the page, click the Auth tab. At the bottom of the page, click Delete Lead Intake Integration. Type the app name to confirm, then click Delete.
That's a Wrap!
That's a Wrap!
Nice work! You have just built a production-grade CRM integration that automatically syncs leads from your API to HubSpot in real time.
You've learned how to:
- Built an outbound webhook integration that maps lead fields from your DynamoDB table to HubSpot contact properties (email, firstname, lastname, phone).
- Connected two independent systems (AWS Lambda and HubSpot CRM) using Python's standard library urllib.request module with Bearer token authentication.
- Tested the full end-to-end flow in Postman proving data lands in both DynamoDB and HubSpot simultaneously, including observing HubSpot's 409 Conflict response for duplicate contacts.
- Secret Mission: Added sync status tracking to DynamoDB records using update_item with UpdateExpression syntax.
Ready to quiz yourself?