Overview
Inbound webhooks turn third-party events into automation runs. Build a workflow, pick Webhook as the trigger source, and the Studio mints a URL that accepts JSON POSTs. Each accepted payload starts one WorkflowRuntargeted at the contact identified in the payload.
- URL shape:
POST https://api.novusflow.tech/v1/webhooks/in/<token> - Body: any JSON object. The full body is available downstream as
{{trigger.<path>}}. - Content-Type:
application/jsonrequired. - Rate limit: 120 requests / minute per source IP. Bursts beyond that return HTTP 429.
Configuring a webhook trigger
In the Studio, click the TRIGGER node and set Trigger source to Inbound webhook. Save and activate the workflow. The inspector then exposes the four configuration knobs:
| Field | Purpose | Default |
|---|---|---|
| Webhook URL | The endpoint you give the third-party app. Token is workspace-scoped. | auto-minted on activation |
| Contact lookup | Match by email / phone / contact id. | |
| JSON path on payload | Dot-path to the lookup value. Top-level or nested. | (same as lookup kind) |
| Create the contact if not found | When checked, an unknown sender becomes a new Contact. | off |
| Signing secret | Optional HMAC shared secret for byte-exact body verification. | blank (unsigned) |
Signing & security
Two security layers stack on top of each other. Most workflows use just the first; the second is required when you can't fully trust the network path (public form endpoints, partners pushing to multiple tenants, audit-grade ingestion).
- Token (always on) — the URL contains a 30-character random token. Anyone with the URL can post; anyone without it cannot.
- HMAC signature (opt-in) — when you set a signing secret in the inspector, the request must carry an
X-Novus-Signatureheader. The value issha256=<hex>where the hex isHMAC-SHA256(secret, raw_body). Signature checks are constant-time. Requests with no header, a wrong signature, or a stale signature (mismatched secret rotation) return HTTP 401.
import crypto from "node:crypto";
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("hex");
// header value is "sha256=<expected>"Payload mapping
Webhook payloads are rarely flat. The JSON path input accepts a dot-path so you can pluck the contact identifier out of nested envelopes (Stripe's data.object wrapper, Shopify's customersub-object, Tally's data.fields[]).
| Sender | Lookup kind | Path | Why |
|---|---|---|---|
| Tally form | data.email | Tally puts the form value at the top of data. | |
| Stripe — payment_intent.succeeded | data.object.customer_email | Customer email lives inside the event envelope. | |
| Shopify — orders/create | customer.email | Customer is a sub-object of the order. | |
| Typeform | form_response.hidden.email | Hidden fields keep PII out of analytics. | |
| Calendly | payload.invitee.email | Booking events carry the invitee email. | |
| Hubspot — contact.creation | id | objectId | Hubspot ids match a custom metadata field. |
Create-on-miss. If no contact matches, the default response is HTTP 404. Toggle Create the contact if not found and the webhook will instead create one with the looked-up email/phone and the configured defaults. The new contact ID is returned in the 202 response so your sender can store it for later.
Real-world examples
Tally form — new lead
{
"eventId": "tally_evt_abc",
"data": {
"email": "[email protected]",
"firstName": "Ayu",
"fields": [
{ "key": "interest", "value": "Premium plan" }
]
}
}Inspector: lookup email, path data.email, create-if-missing on, first-name default Ayu. The workflow then sees {{trigger.data.fields[0].value}} as the interest tag.
Stripe — payment_intent.succeeded
{
"id": "evt_3PaIDx",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_3PaIDx",
"amount_received": 150000,
"currency": "idr",
"customer_email": "[email protected]",
"metadata": { "invoice_number": "INV-1024" }
}
}
}Inspector: lookup email, path data.object.customer_email, signing secret set to your Stripe webhook signing secret. Downstream nodes pull the invoice number via {{trigger.data.object.metadata.invoice_number}}.
Shopify — orders/create
{
"id": 820982911946154508,
"name": "#1001",
"customer": {
"email": "[email protected]",
"first_name": "Dewi",
"phone": "+6281234567890"
},
"total_price": "299000.00",
"currency": "IDR"
}Inspector: lookup email, path customer.email. Order details flow through to a WhatsApp template via {{trigger.name}} and {{trigger.total_price}}.
Typeform — hidden contact id
{
"event_id": "tf_evt",
"form_response": {
"form_id": "abc",
"hidden": { "contact_id": "cln3xy4z9000…" }
}
}Inspector: lookup Contact ID, path form_response.hidden.contact_id. This pattern keeps the form anonymous to Typeform while still routing the run to the right person — useful for in-app surveys.
Response codes
| Code | Meaning | Sender action |
|---|---|---|
| 202 Accepted | Run started. Response body carries { runId, contactId }. | Treat as success. |
| 401 Unauthorized | Signing secret set but signature missing or wrong. | Rotate or re-sign. |
| 404 Not Found | Token doesn't match an ACTIVE WEBHOOK workflow, or contact lookup failed (create-on-miss off). | Check URL or enable create-on-miss. |
| 422 Unprocessable Entity | Lookup path resolved to nothing or wrong type. | Fix the JSON path or the sender's shape. |
| 429 Too Many Requests | Per-IP rate limit hit (120/min). | Backoff + retry. |
| 5xx | Platform error. | Retry with exponential backoff. |