Inbound Webhook Mappings
Connect external services like Clerk, Stripe, and Supabase to Molted Email without writing code.
Inbound webhook mappings let you receive webhooks from external services and automatically route data into Molted features -- sync contacts, subscribe to lists, or trigger journeys. No code required.
How it works
- Create an inbound webhook mapping in Molted
- Get a unique webhook URL (e.g.,
https://api.molted.email/v1/hooks/ink_abc123...) - Paste that URL into your provider's webhook settings
- Configure where the email lives in the payload, field mappings, and actions
When the provider sends a webhook, Molted extracts the email address, maps fields, and executes your configured actions.
Creating a webhook mapping
Via CLI
molted inbound-hooks create \
--name "Clerk Signups" \
--source clerk \
--email-path "data.email_addresses[0].email_address" \
--field-map "name=data.first_name" \
--field-map "clerkId=data.id" \
--action "sync_contact" \
--action "subscribe_to_list:lst_abc"Append :confirm to require double opt-in on that subscription (see Double opt-in below):
--action "subscribe_to_list:lst_abc:confirm"Via API
POST /v1/agent/inbound-hooks
{
"name": "Clerk Signups",
"source": "clerk",
"emailPath": "data.email_addresses[0].email_address",
"fieldMappings": {
"name": "data.first_name",
"clerkId": "data.id"
},
"actions": [
{ "type": "sync_contact" },
{ "type": "subscribe_to_list", "listId": "lst_abc", "requireConfirmation": true }
]
}requireConfirmation is optional and defaults to false.
The response includes a urlToken field. Your webhook URL is:
https://api.molted.email/v1/hooks/{urlToken}Email path syntax
The emailPath field uses dot notation with array index support to extract the email address from any JSON payload structure:
| Provider | Email path |
|---|---|
| Clerk | data.email_addresses[0].email_address |
| Stripe | data.object.email |
| Supabase Auth | record.email |
| Custom | Any valid dot-notation path |
Field mappings
Map fields from the incoming payload to contact metadata:
{
"name": "data.first_name",
"company": "data.organization.name",
"plan": "data.metadata.plan"
}Mapped fields are stored as contact metadata (via the sync_contact action).
The extracted email may be a plain address or a display-name form (Jane Doe <jane@example.com>). A new contact is stored as the bare address, lowercased, with the domain in its ASCII form (a Unicode or fullwidth domain is converted and a trailing dot dropped), the same canonical form the Lists API stores.
Action types
Actions execute sequentially in the order configured.
| Action | Description | Parameters |
|---|---|---|
sync_contact | Upsert contact with mapped fields: finds the contact by any spelling of the address (case, +tag, Gmail dots, display name) and updates it, or creates it. A mapped name (or firstName) replaces the contact's name; mapped fields are merged into its metadata | None |
subscribe_to_list | Add contact to a list | listId (required), requireConfirmation (optional, default false) |
trigger_journey | Fire an event to start a journey | eventName (required) |
If an action fails, subsequent actions still execute. Failed actions are logged but don't block the webhook response.
subscribe_to_list fails the same way as a missing list (List <id> not found) when the list is archived: an archived list refuses every new subscriber, from this action and from POST /v1/agent/lists/:id/subscribers alike.
Double opt-in for subscribe_to_list
subscribe_to_list sits behind a public, token-authenticated form URL, so by
default anyone who has the URL can subscribe any address they type in. Two
safeguards apply regardless of requireConfirmation:
- An address that has unsubscribed from the list is never silently
reactivated by a form submission. With
requireConfirmation: false(the default), a resubmission of an unsubscribed address is a no-op -- it stays unsubscribed. WithrequireConfirmation: true, a resubmission starts a fresh confirmation cycle instead of reactivating directly. This holds for every spelling the Lists API treats as the same address, and after the contact was deleted and created again or had its email changed: the unsubscribe outlives the contact. It also holds for the other Gmail domain (jane@googlemail.comafterjane@gmail.comunsubscribed, and the reverse). - An address whose other Gmail domain is already active on the list is
not added a second time (one mailbox, one subscription), and one whose
other Gmail domain is still
pendinggets no second confirmation. - A suppressed address (bounced, complained, or manually suppressed) is never activated by this action, on or off confirmation.
The public webhook response never reveals which of these happened -- the
action always reports status: "ok" unless the webhook mapping itself is
misconfigured (e.g. an unknown listId).
Set "requireConfirmation": true on the action to require the address owner
to confirm before they're subscribed -- useful for a public waitlist or
signup form where you don't want to let someone subscribe a third party's
address. A confirmation email is not sent directly by the webhook; instead
the address goes to pending and a list.subscribe.pending journey event is
emitted (the same event the Lists API emits for its own
double opt-in). Configure a journey on that trigger event to send the
confirmation email, using the event's confirmationUrl field -- see
Lists: Double opt-in for the journey setup.
Resubmitting the same address while it is still pending does not send a
second confirmation email.
Managing webhooks
# List all inbound webhook mappings
molted inbound-hooks list
# Get details for a specific webhook
molted inbound-hooks get <id>
# Update a webhook (e.g., disable it)
molted inbound-hooks update <id> --enabled false
# Delete a webhook
molted inbound-hooks delete <id>
# View recent delivery logs
molted inbound-hooks logs <id>
# Dry-run a sample payload: no side effects
molted inbound-hooks test <id> --payload '{"data":{"email":"a@b.com"}}'Testing a webhook mapping (dry run)
Before pointing a real provider at the receiver URL, test the mapping against a sample payload. This runs the same email and field extraction a real delivery would, and reports the actions it would take -- but doesn't execute any of them: no contact upsert, no list subscription, no journey event, and no delivery log row.
Via CLI
molted inbound-hooks test <id> --payload '{"data":{"email_addresses":[{"email_address":"a@b.com"}],"first_name":"Ada"}}'
# Or from a file
molted inbound-hooks test <id> --payload @sample-payload.jsonVia API
POST /v1/agent/inbound-hooks/:id/test
{
"payload": { "data": { "email": "a@b.com", "first_name": "Ada" } }
}{
"wouldSucceed": true,
"enabled": true,
"extracted": { "email": "a@b.com", "fields": { "name": "Ada" } },
"actions": [
{ "type": "sync_contact", "description": "Upsert contact with mapped fields" }
]
}If the sample payload doesn't yield a valid email at emailPath, wouldSucceed is false and actions is empty -- a real delivery with that payload wouldn't run any action either. If the mapping is disabled, wouldSucceed is false and enabled is false -- a real delivery would be rejected (422) before any action runs.
Security model
This MVP does not perform provider-side signature verification (Svix, HMAC, etc.). The receiver endpoint is authenticated by the URL token alone -- anyone with the URL can trigger the configured actions. Treat tokens as secrets:
- Paste the URL directly into the provider dashboard; avoid logging or committing it
- If a URL leaks, delete and recreate the webhook mapping to rotate the token
- Per-token rate limit: 120 requests per 60 seconds. Excess deliveries receive
429
Signature verification is tracked as a post-MVP enhancement.
Delivery logs
Each webhook delivery is logged with:
- Timestamp and source IP
- Processing status (
processed,partial,failed,rejected) - The extracted email and field values, each capped at 2000 bytes (not the raw payload -- see below)
- A sha256 hash of the exact bytes received, so a delivery can be verified byte-for-byte once you have the original payload again (a support ticket, the sender's own copy)
- Per-action execution results
- Error messages for failed actions
This log row never stores the raw payload -- it can carry far more than a mapping is configured to pull out (a full Stripe or Clerk object, for example). The extracted fields plus the payload hash are enough to understand what a delivery did, and to check a payload you get hold of later against test matches. This is about the log row specifically, not every table: a trigger_journey action still passes the full raw payload into the journey event it creates (so journey trigger_conditions can match against it), same as before this change -- that's unrelated to the delivery log.
Retention: delivery logs older than 30 days are deleted automatically. Deleting a contact (GDPR erasure) also clears any log rows carrying that contact's extracted email.
molted inbound-hooks logs <id> --limit 10Provider setup examples
Clerk
- Create the webhook mapping:
molted inbound-hooks create \ --name "Clerk user.created" \ --source clerk \ --email-path "data.email_addresses[0].email_address" \ --field-map "name=data.first_name" \ --action "sync_contact" \ --action "subscribe_to_list:<your-list-id>" - In the Clerk Dashboard, go to Webhooks and add a new endpoint
- Paste your Molted webhook URL
- Subscribe to the
user.createdevent
Stripe
- Create the webhook mapping:
molted inbound-hooks create \ --name "Stripe customer.created" \ --source stripe \ --email-path "data.object.email" \ --field-map "name=data.object.name" \ --field-map "stripeId=data.object.id" \ --action "sync_contact" - In the Stripe Dashboard, go to Developers > Webhooks
- Add your Molted webhook URL
- Select the
customer.createdevent