MOLTED EMAIL

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

  1. Create an inbound webhook mapping in Molted
  2. Get a unique webhook URL (e.g., https://api.molted.email/v1/hooks/ink_abc123...)
  3. Paste that URL into your provider's webhook settings
  4. 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:

ProviderEmail path
Clerkdata.email_addresses[0].email_address
Stripedata.object.email
Supabase Authrecord.email
CustomAny 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.

ActionDescriptionParameters
sync_contactUpsert 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 metadataNone
subscribe_to_listAdd contact to a listlistId (required), requireConfirmation (optional, default false)
trigger_journeyFire an event to start a journeyeventName (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. With requireConfirmation: 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.com after jane@gmail.com unsubscribed, 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 pending gets 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.json

Via 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 10

Provider setup examples

Clerk

  1. 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>"
  2. In the Clerk Dashboard, go to Webhooks and add a new endpoint
  3. Paste your Molted webhook URL
  4. Subscribe to the user.created event

Stripe

  1. 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"
  2. In the Stripe Dashboard, go to Developers > Webhooks
  3. Add your Molted webhook URL
  4. Select the customer.created event