Home / Stripe Webhook to CRM

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

Python (FastAPI webhook handler)
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.

Request body
  • 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.
Response body
  • output  -  the CRM-shaped record. Send this directly to your CRM's create/update endpoint.
  • cached  -  true if the function was reused; false on 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.

payload (Stripe event)
{
  "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
  }}
}
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)."
output (verified response)
{
  "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.

target
"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)."
output
{
  "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.

payload
{
  "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"
  }}
}
target
"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')."
output
{
  "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.

payload
{
  "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"}
    }}]}
  }}
}
target
"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)."
output
{
  "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.

target
"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']."
output
{
  "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.

Stripe SDK (Python)
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

StatusMeaningWhat to do
200Success. output contains the CRM-shaped record.-
400Body missing payload or target, or invalid JSON.Check that both fields are present and the body parses.
401Missing or invalid Bearer token.Include Authorization: Bearer sk_....
402API key has zero credits remaining.Top up at streamfix.dev.
422The 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.
502The 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

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

9. Get an API key

Free trial credits on signup. Same Bearer key works for /v1/map and StreamFix's other endpoints.

Sign up

Related transformations