Stripe Webhook to CRM: Map Events to Salesforce, HubSpot, and Airtable
Updated April 2026.
Stripe webhooks arrive in their own deeply-nested shape. Your CRM wants something else, and that "something else" depends on which CRM and which event. This page is the working reference: a 30-second quickstart, five worked examples (customer.created in Salesforce shape, customer.created in HubSpot shape, invoice.paid, subscription.created with computed MRR for Airtable, charge.failed as a support ticket), error reference, and a structural comparison with the no-code platforms (Zapier, Make, Tray, Workato).
New here? The full /v1/map endpoint reference (contract, sandbox scope, errors, limits, all use cases) lives at Transformation API overview →. This page is the Stripe-specific deep dive.
1. 30-second quickstart
import httpx, stripe from fastapi import FastAPI, Request, HTTPException app = FastAPI() TARGET = ( "Salesforce-style Contact: object with FirstName (split name on first " "space), LastName, Email, Phone, MailingCity (address.city), " "MailingCountry (uppercase address.country), Stripe_Customer_Id__c " "(data.object.id), LeadSource='Stripe', IsActive (NOT deleted), " "CreatedDate (ISO 8601 from 'created' unix timestamp)." ) @app.post("/stripe-webhook") async def stripe_webhook(req: Request): # 1. Verify Stripe signature (see section 4 below). payload = await req.body() sig = req.headers.get("stripe-signature") try: event = stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET) except stripe.error.SignatureVerificationError: raise HTTPException(400, "Invalid signature") if event["type"] != "customer.created": return {"ok": True} # 2. Map Stripe event -> Salesforce contact shape via /v1/map. async with httpx.AsyncClient() as c: r = await c.post( "https://streamfix.dev/v1/map", headers={"Authorization": "Bearer sk_..."}, json={"payload": event, "target": TARGET}, timeout=30, ) contact = r.json()["output"] await salesforce.contacts.create(contact) return {"ok": True}
The first call with a given Stripe event type and target description compiles a transformation function and runs it. Every subsequent call with the same event shape and target hits the cache and runs in milliseconds. The cache key is (input shape, target text); same shape + same target = same code = same output.
2. Endpoint reference
POST /v1/map. Auth via Authorization: Bearer sk_YOUR_KEY.
payload- the Stripe event JSON, exactly as received from the webhook.target- plain-English description of the desired output shape. This is the cache key, so keep wording stable.
output- the CRM-shaped record. Send this directly to your CRM's create/update endpoint.cached-trueif the function was reused;falseon the first call with this(shape, target)pair.fingerprint,elapsed_ms,code_length- debugging metadata.
3. Worked examples
Each example below shows the actual Stripe payload, the target text, and the verified response from /v1/map. Stripe event shapes evolve over time; check the official event reference for current fields. The transformation logic is in the target string, so when Stripe adds an optional field, you adjust the string and re-deploy.
3.1 customer.created -> Salesforce-style Contact
Map a Stripe customer.created event into the Salesforce Contact object's expected field names: FirstName / LastName (Stripe gives you a single name), MailingCity / MailingCountry (Stripe nests these in address), and a custom field Stripe_Customer_Id__c for round-tripping.
{
"id": "evt_1Mq",
"type": "customer.created",
"created": 1742832000,
"data": {"object": {
"id": "cus_NfE",
"email": "[email protected]",
"name": "Alice Johnson",
"phone": "+14155551234",
"address": {"city": "San Francisco", "country": "us", "state": "CA"},
"deleted": false
}}
}
"Salesforce-style Contact: object with FirstName (split name on first space), LastName, Email, Phone, MailingCity (address.city), MailingCountry (uppercase address.country), Stripe_Customer_Id__c (data.object.id), LeadSource='Stripe', IsActive (NOT deleted), CreatedDate (ISO 8601 from 'created' unix timestamp)."
{
"FirstName": "Alice",
"LastName": "Johnson",
"Email": "[email protected]",
"Phone": "+14155551234",
"MailingCity": "San Francisco",
"MailingCountry": "US",
"Stripe_Customer_Id__c": "cus_NfE",
"LeadSource": "Stripe",
"IsActive": true,
"CreatedDate": "2025-03-24T16:00:00Z"
}
3.2 customer.created -> HubSpot contact properties
HubSpot's contacts API wraps everything in a properties object and uses lowercase field names (firstname, lifecyclestage). Same Stripe event, different target string.
"HubSpot contact: object with 'properties' key. Properties: email, firstname, lastname (split name on first space), phone, country (uppercase address.country), lifecyclestage='lead', hs_lead_status='STRIPE_CUSTOMER', stripe_customer_id (data.object.id)."
{
"properties": {
"email": "[email protected]",
"firstname": "Erin",
"lastname": "McLeod",
"phone": "+15555551234",
"country": "US",
"lifecyclestage": "lead",
"hs_lead_status": "STRIPE_CUSTOMER",
"stripe_customer_id": "cus_xyz"
}
}
3.3 invoice.paid -> flat invoice record
Stripe sends amounts in cents inside data.object.amount_paid. Most downstream systems want dollars (or the local currency unit) as a float. The target string handles the conversion plus the unit-timestamp -> ISO step.
{
"id": "evt_3Nx", "type": "invoice.paid", "created": 1742900000,
"data": {"object": {
"id": "in_1Nx", "customer": "cus_abc",
"customer_email": "[email protected]",
"amount_paid": 99000, "currency": "usd", "status": "paid",
"lines": {"data": [{"description": "Pro plan (annual)", "amount": 99000}]},
"hosted_invoice_url": "https://invoice.stripe.com/i/abc"
}}
}
"Object with: invoice_id, customer_id, customer_email, total_dollars (amount_paid/100, float), currency (uppercase), status, line_count (length of lines.data), invoice_url (hosted_invoice_url), paid_at (ISO 8601 from 'created')."
{
"invoice_id": "in_1Nx",
"customer_id": "cus_abc",
"customer_email": "[email protected]",
"total_dollars": 990.0,
"currency": "USD",
"status": "paid",
"line_count": 1,
"invoice_url": "https://invoice.stripe.com/i/abc",
"paid_at": "2025-03-25T10:53:20Z"
}
Currency precision caveat: Stripe sends most currency amounts in the smallest unit (cents for USD/EUR/GBP), so amount_paid/100 is correct. But zero-decimal currencies like JPY, KRW, and CLP are sent in the unit itself - dividing by 100 there gives you the wrong number. If you handle multiple currencies, branch in your target text on data.object.currency, or scale by Stripe's currency reference in your handler.
3.4 subscription.created -> Airtable record with computed MRR
A real example of why "no-code" mappers struggle: computed fields. The Airtable record needs an MRR field, but Stripe's price is in cents and the billing interval can be monthly or yearly. Yearly subscriptions need unit_amount/1200 to convert to monthly recurring revenue. The conditional goes in the target text.
{
"id": "evt_4Sub", "type": "customer.subscription.created", "created": 1742832000,
"data": {"object": {
"id": "sub_abc123", "customer": "cus_xyz", "status": "active",
"current_period_start": 1742832000, "current_period_end": 1745510400,
"items": {"data": [{"price": {
"unit_amount": 4900, "currency": "usd",
"recurring": {"interval": "month"}
}}]}
}}
}
"Airtable record: object with 'fields' key. Inside fields: subscription_id, customer_id, status, MRR (float, unit_amount/100 if monthly, unit_amount/1200 if yearly), period_start (ISO from current_period_start), period_end (ISO from current_period_end)."
{
"fields": {
"subscription_id": "sub_abc123",
"customer_id": "cus_xyz",
"status": "active",
"MRR": 49.0,
"period_start": "2025-03-24T16:00:00Z",
"period_end": "2025-04-24T16:00:00Z"
}
}
3.5 charge.failed -> support ticket
Routing payment failures to your support tool means producing a ticket-shaped record - subject line, multi-line body, priority, tags. The target text sets a priority of high when the failure code is card_declined, otherwise normal. Same pattern works for Zendesk, Freshdesk, Linear, or any ticketing API.
"Support ticket: object with subject ('Payment failed: '), body (multi-line: customer email, name, amount in dollars, failure_message), priority (high if failure_code='card_declined' else normal), customer_email (billing_details.email), tags=['stripe', 'payment-failed']."
{
"subject": "Payment failed: card_declined",
"body": "Customer email: [email protected]\nCustomer name: Sam Lee\nAmount: $49.00\nFailure message: Your card was declined.",
"priority": "high",
"customer_email": "[email protected]",
"tags": ["stripe", "payment-failed"]
}
4. Signature verification (do this first)
/v1/map only handles the mapping step. Before calling it, verify the webhook came from Stripe using their official signature verification. Without this, anyone can POST to your endpoint and trigger CRM writes.
import stripe payload = await request.body() sig = request.headers.get("stripe-signature") event = stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET) # raises stripe.error.SignatureVerificationError on mismatch
After verification, pass the parsed event object as the payload to /v1/map.
5. Errors and status codes
| Status | Meaning | What to do |
|---|---|---|
200 | Success. output contains the CRM-shaped record. | - |
400 | Body missing payload or target, or invalid JSON. | Check that both fields are present and the body parses. |
401 | Missing or invalid Bearer token. | Include Authorization: Bearer sk_.... |
402 | API key has zero credits remaining. | Top up at streamfix.dev. |
422 | The generated function ran but raised an exception (e.g. an event type whose shape doesn't match what the target string described). | Branch on event["type"] in your handler and use a different target per event type. Or expand the target to handle the shape variation. |
502 | The LLM did not return code (rare). | Retry. Webhooks should retry on 5xx by default; Stripe will redeliver. |
Practical pattern: reply 200 to Stripe quickly, then queue the mapping work. If /v1/map returns a 5xx, your queue retries; Stripe doesn't see the failure and doesn't redeliver.
6. Limits and behavior
- Caching by event type and target: the cache key is
(input shape, target text). Acustomer.createdevent and aninvoice.paidevent have different shapes, so they get different cached functions. Same event type with the same target text always hits the cache after the first call. - Determinism: the generated function is deterministic on its input. Same Stripe event in always produces the same CRM record out, even months later.
- Sandbox: generated code runs in an AST-validated Python sandbox. No network, no filesystem, no
osorsubprocess. Stdlib data modules are whitelisted (datetime,json,re,decimal, etc.). - Timeout: 120 seconds for the LLM call (uncached). Cached calls return in milliseconds.
- Stripe sends event payloads up to 256 KB. The endpoint shows the LLM the first 4,000 characters when generating the function. Cached calls run on the full payload.
7. Alternatives and how this differs
The Stripe-to-CRM market is well-served. Here's how /v1/map differs structurally. Pricing changes; check each tool's site for current numbers.
| Tool | Shape | Customization | Best for |
|---|---|---|---|
| Zapier | No-code platform; pre-built triggers and actions | Field mapping in their UI; "Code by Zapier" steps for custom logic | Operators who want a visual editor and have lots of other Zapier-connected tools. |
| Make.com | No-code platform with a flow-graph editor | Visual mapper with formula language; iterators for arrays | Operators who need branching/looping logic without code. |
| Tray.io | Enterprise iPaaS with API + UI | Workflow editor with custom JS steps | Companies with established procurement processes; multi-tenant deployment. |
| Workato | Enterprise iPaaS | Recipes (their workflow format) with a formula engine | Enterprise IT-led integration projects. |
StreamFix /v1/map |
HTTP API (auth via Bearer key); single endpoint | Target description in plain English; conditional logic, computed fields, denormalization all in the string | Engineers who want their existing webhook handler to map Stripe events without maintaining a Zapier-style mapping in a separate UI. |
The structural difference: the four no-code platforms above are complete workflow engines - they trigger on the Stripe webhook, do the mapping, and call your CRM, all in their UI. /v1/map is just the mapping step, called from your own webhook handler. If you don't have a handler (operators), the no-code platforms are easier. If you have a handler (engineers) and just want the mapping done, this fits cleaner.
8. When NOT to use this
- You don't have an application yet, just a need to wire Stripe to a CRM. Zapier or Make.com let you do the whole flow without writing code.
- The mapping is trivial and stable. If you only need three fields from
customer.createdand they map 1:1 to your CRM, write the mapping in 10 lines and skip the API call. - You need PII to never leave your network. The first call sends the input shape and target text to OpenAI to generate the code. Cached calls run locally in our sandbox and don't send anything to OpenAI - but the first call does. If you need air-gapped processing, this isn't it.
- You need the no-code platform's full feature set (scheduled tasks, retry queues, multi-tool flows).
/v1/mapdoes one thing - the JSON-to-JSON mapping. Everything else (queueing, retries, idempotency, dedup) is your handler's job.
9. Get an API key
Free trial credits on signup. Same Bearer key works for /v1/map and StreamFix's other endpoints.