Webhook recipes

Each recipe is three pieces that work together: an outgoing webhook with a body template that hands a Contact to the tool, an incoming event that takes the tool's answer back, and a Sequence fragment that waits for it. Replace every YOUR_... value with your own. Every recipe passes the template lint and curviate motion lint.

No recipe sends the text of a reply to another tool: the templates carry the Contact's profile fields and ids only. A tool's endpoint and field names can change, so check each against the tool's own documentation, linked in every recipe, before you rely on it.

Instantly

A LinkedIn invitation that is not accepted hands the Contact to an Instantly campaign; Instantly's reply_received moves the Enrollment on. The same incoming URL also takes a lead_scored post from your scoring step.

Checked against: developer.instantly.ai/api-reference/lead/create-lead, developer.instantly.ai/guides/webhook-events

1. The webhook

{
  "source": "motion",
  "events": [
    "motion.custom_event"
  ],
  "custom_event_names": [
    "start_email_sequence"
  ],
  "request_url": "https://api.instantly.ai/api/v2/leads",
  "headers": [
    {
      "key": "Authorization",
      "value": "Bearer YOUR_INSTANTLY_API_KEY"
    }
  ]
}

The body_template, as sent to the tool:

{
  "campaign": "YOUR_INSTANTLY_CAMPAIGN_ID",
  "email": "{{contact.email}}",
  "first_name": "{{first_name}}",
  "last_name": "{{last_name}}",
  "company_name": "{{company}}",
  "job_title": "{{job_title}}",
  "personalization": "Saw your profile ({{headline}}) on LinkedIn.",
  "skip_if_in_campaign": true,
  "custom_variables": {
    "linkedin_url": "{{linkedin_url}}",
    "curviate_enrollment_id": "{{enrollment_id}}",
    "score": "{{enrollment.score}}",
    "phone": "{{contact.extra.phone}}"
  }
}

2. The incoming event

{
  "event_name_field": "event_type",
  "match": {
    "email": "lead_email"
  }
}

3. The Sequence

{
  "on_reply": "end",
  "steps": [
    {
      "id": "invite",
      "do": "invite.send",
      "with": {
        "withdraw_after": "21d"
      }
    },
    {
      "id": "accepted",
      "until": "invite.accepted",
      "timeout": "21d",
      "then": "opener",
      "else": "scored"
    },
    {
      "id": "opener",
      "do": "message.send",
      "with": {
        "text": "Hi {{first_name}}, thanks for connecting.",
        "fallback": "Thanks for connecting."
      },
      "next": "end"
    },
    {
      "id": "scored",
      "until": "event.lead_scored",
      "timeout": "7d",
      "save": {
        "score": "score"
      },
      "then": "is_hot",
      "else": "end"
    },
    {
      "id": "is_hot",
      "if": {
        "enrollment.score": {
          "gte": 50
        }
      },
      "then": "start_email",
      "else": "end"
    },
    {
      "id": "start_email",
      "emit": "start_email_sequence",
      "next": "replied"
    },
    {
      "id": "replied",
      "until": "event.reply_received",
      "timeout": "14d",
      "then": "end",
      "else": "end"
    }
  ]
}

Smartlead

A Contact who has not accepted after 21 days joins a Smartlead campaign; Smartlead's EMAIL_REPLY moves the Enrollment on.

Checked against: api.smartlead.ai/reference/add-leads-to-a-campaign-by-id, api.smartlead.ai/guides/webhook-integration

1. The webhook

{
  "source": "motion",
  "events": [
    "motion.custom_event"
  ],
  "custom_event_names": [
    "start_email_sequence"
  ],
  "request_url": "https://server.smartlead.ai/api/v1/campaigns/YOUR_CAMPAIGN_ID/leads?api_key=YOUR_SMARTLEAD_API_KEY",
  "headers": []
}

The body_template, as sent to the tool:

{
  "lead_list": [
    {
      "email": "{{contact.email}}",
      "first_name": "{{first_name}}",
      "last_name": "{{last_name}}",
      "company_name": "{{company}}",
      "location": "{{location}}",
      "linkedin_profile": "{{linkedin_url}}",
      "custom_fields": {
        "job_title": "{{job_title}}",
        "curviate_enrollment_id": "{{enrollment_id}}"
      }
    }
  ]
}

2. The incoming event

{
  "event_name_field": "event_type",
  "match": {
    "email": "to_email"
  }
}

3. The Sequence

{
  "on_reply": "end",
  "steps": [
    {
      "id": "invite",
      "do": "invite.send",
      "with": {
        "withdraw_after": "21d"
      }
    },
    {
      "id": "accepted",
      "until": "invite.accepted",
      "timeout": "21d",
      "then": "end",
      "else": "start_email"
    },
    {
      "id": "start_email",
      "emit": "start_email_sequence",
      "next": "replied"
    },
    {
      "id": "replied",
      "until": "event.email_reply",
      "timeout": "14d",
      "save": {
        "campaign": "campaign_name"
      },
      "then": "thanks",
      "else": "end"
    },
    {
      "id": "thanks",
      "if": {
        "enrollment.campaign": {
          "exists": true
        }
      },
      "then": "end",
      "else": "end"
    }
  ]
}

HubSpot

An accepted invitation creates the HubSpot contact; a HubSpot workflow's Send a webhook action reports a booked meeting back, which ends the Enrollment and keeps the deal stage on the Contact.

Checked against: developers.hubspot.com/docs/api-reference/crm-contacts-v3/basic/post-crm-v3-objects-contacts

1. The webhook

{
  "source": "motion",
  "events": [
    "enrollment.accepted"
  ],
  "custom_event_names": null,
  "request_url": "https://api.hubapi.com/crm/v3/objects/contacts",
  "headers": [
    {
      "key": "Authorization",
      "value": "Bearer YOUR_HUBSPOT_PRIVATE_APP_TOKEN"
    }
  ]
}

The body_template, as sent to the tool:

{
  "properties": {
    "email": "{{contact.email}}",
    "firstname": "{{first_name}}",
    "lastname": "{{last_name}}",
    "company": "{{company}}",
    "jobtitle": "{{job_title}}"
  }
}

2. The incoming event

{
  "event_name": "meeting_booked",
  "match": {
    "email": "email"
  }
}

3. The Sequence

{
  "on_reply": "end",
  "steps": [
    {
      "id": "invite",
      "do": "invite.send",
      "with": {
        "withdraw_after": "21d"
      }
    },
    {
      "id": "accepted",
      "until": "invite.accepted",
      "timeout": "21d",
      "then": "booked",
      "else": "end"
    },
    {
      "id": "booked",
      "until": "event.meeting_booked",
      "timeout": "30d",
      "save_to_contact": {
        "hubspot_stage": "dealstage"
      },
      "then": "end",
      "else": "nudge"
    },
    {
      "id": "nudge",
      "do": "message.send",
      "with": {
        "text": "Hi {{first_name}}, is a short call still useful?",
        "fallback": "Is a short call still useful?"
      },
      "next": "end"
    }
  ]
}

n8n

Every custom event reaches an n8n Webhook node with the Enrollment id; the workflow posts its verdict back with that id, so the match never depends on an email.

Checked against: docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/, docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/

1. The webhook

{
  "source": "motion",
  "events": [
    "motion.custom_event"
  ],
  "custom_event_names": [
    "qualify"
  ],
  "request_url": "https://YOUR_N8N_HOST/webhook/YOUR_WEBHOOK_PATH",
  "headers": [
    {
      "key": "X-N8n-Token",
      "value": "YOUR_HEADER_AUTH_VALUE"
    }
  ]
}

The body_template, as sent to the tool:

{
  "event": "{{event}}",
  "enrollment_id": "{{enrollment_id}}",
  "motion": "{{motion.name}}",
  "step": "{{step_id}}",
  "contact": {
    "name": "{{contact.full_name}}",
    "linkedin_url": "{{linkedin_url}}",
    "company": "{{company}}"
  }
}

2. The incoming event

{
  "event_name": "qualified",
  "match": {
    "enrollment_id": "enrollment_id"
  }
}

3. The Sequence

{
  "on_reply": "end",
  "steps": [
    {
      "id": "invite",
      "do": "invite.send",
      "with": {
        "withdraw_after": "21d"
      }
    },
    {
      "id": "accepted",
      "until": "invite.accepted",
      "timeout": "21d",
      "then": "qualify",
      "else": "end"
    },
    {
      "id": "qualify",
      "emit": "qualify",
      "next": "verdict"
    },
    {
      "id": "verdict",
      "until": "event.qualified",
      "timeout": "3d",
      "save": {
        "tier": "tier"
      },
      "then": "is_a",
      "else": "end"
    },
    {
      "id": "is_a",
      "if": {
        "enrollment.tier": {
          "eq": "a"
        }
      },
      "then": "pitch",
      "else": "end"
    },
    {
      "id": "pitch",
      "do": "message.send",
      "with": {
        "text": "Hi {{first_name}}, worth a short call this week?",
        "fallback": "Worth a short call this week?"
      },
      "next": "end"
    }
  ]
}

Before you switch it on

  • Send a test from the webhook, then post a real example to the incoming URL so it captures a sample ( Incoming events). A test reaches the tool like a real event and may create a record.
  • A request that timed out after the tool had already created its record is retried, so a template that creates a record can create it twice. Use the tool's own skip-if-exists option where it has one, and see Delivery & retries.
  • A template that uses the Contact's email is not sent for a Contact without one; the delivery shows as skipped_no_email.