Minimal AWS Lambda function that receives Ballpoint webhook events, verifies the HMAC-SHA256 signature, and rejects stale/replayed requests.
Required headers:
X-Ballpoint-Signature: sha256=<hex>
X-Ballpoint-Timestamp: 2026-03-01T01:23:45Z (ISO 8601)
X-Ballpoint-Event-Id: evt_...
Signature base string:
HMAC-SHA256(secret, timestamp + raw_body) — no delimiter
Validation checklist:
✓ Verify signature (constant-time comparison)
✓ Reject timestamps older than 5 minutes
✓ Reject timestamps more than 2 minutes in the future
✓ Reject duplicate event IDs (store with TTL)
✓ Return 2xx quickly — do heavy processing async
Verify signatures against the exact raw body bytes. If your API Gateway integration sets isBase64Encoded: true, decode the body before verifying. The handler does this automatically — see handler.js lines 29-34.
# Zip the handler
zip handler.zip handler.js
# Create the function
aws lambda create-function \
--function-name ballpoint-webhook \
--runtime nodejs20.x \
--handler handler.handler \
--zip-file fileb://handler.zip \
--role arn:aws:iam::YOUR_ACCOUNT:role/YOUR_LAMBDA_ROLEaws lambda update-function-configuration \
--function-name ballpoint-webhook \
--environment "Variables={WEBHOOK_SECRET=whsec_your_secret_here}"Option A — HTTP API (recommended):
aws apigatewayv2 create-api \
--name ballpoint-webhook \
--protocol-type HTTP \
--target arn:aws:lambda:us-east-1:YOUR_ACCOUNT:function:ballpoint-webhookHTTP API passes the raw body as a string by default — no extra configuration needed.
Option B — REST API:
If using REST API, ensure the integration passes the raw body through. You may need to configure binary media types or a body mapping template.
The create-api --target shortcut automatically creates a Lambda permission and
a $default catch-all route. If you set up the integration manually (console,
CloudFormation, etc.), you may need to add the permission yourself:
aws lambda add-permission \
--function-name ballpoint-webhook \
--statement-id apigateway-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:us-east-1:YOUR_ACCOUNT:API_ID/*"Optional — explicit route: The $default catch-all works fine for a
single-purpose Lambda. If you want a specific route instead:
aws apigatewayv2 create-route \
--api-id API_ID \
--route-key "POST /webhooks/ballpoint" \
--target integrations/INTEGRATION_IDOptional — stage deployment: HTTP API creates a $default stage with
auto-deploy enabled. If you disabled auto-deploy, deploy the stage manually for
changes to take effect.
Your webhook URL will look like:
https://abc123.execute-api.us-east-1.amazonaws.com/webhooks/ballpoint
Share this with Ballpoint and we'll configure webhook delivery.
# Generate a test signature
SECRET="whsec_test"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
BODY='{"type":"order.status_changed","data":{"order_id":"ord_test","display_status":"printing"}}'
SIG=$(echo -n "${TIMESTAMP}${BODY}" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
# Call your endpoint
curl -X POST https://YOUR_API_GATEWAY_URL/webhooks/ballpoint \
-H "Content-Type: application/json" \
-H "X-Ballpoint-Signature: sha256=$SIG" \
-H "X-Ballpoint-Timestamp: $TIMESTAMP" \
-H "X-Ballpoint-Event-Id: evt_test_001" \
-d "$BODY"- Concatenate the timestamp header + raw request body (no delimiter)
- HMAC-SHA256 with your webhook secret
- Compare to the
X-Ballpoint-Signatureheader (after strippingsha256=prefix) - Use constant-time comparison to prevent timing attacks
See Webhook Contract above for the full validation checklist (timestamp window, deduplication, etc.).