> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Consume journey webhook node calls

> Wire an HTTPS endpoint to the journey builder's webhook node: the payload contract, URL and method rules, the 10-second response budget, fire-and-forget delivery semantics, and a worked consumer that serves several journeys from one endpoint.

# Consume journey webhook node calls

The journey builder's **Webhook** node calls an external HTTPS endpoint each time a contact traverses that node in a journey graph. It is the way to hand a per-contact hand-off to your own systems mid-journey: sync the contact to a CRM when they reach a stage, notify an order system when a win-back journey re-qualifies them, or fan a journey event out to an internal bus.

This guide is for the engineer on the receiving end. It covers what Orbit sends, what it guarantees, what it deliberately does not guarantee, and how to build an endpoint that behaves correctly when a delivery fails, times out, or arrives twice.

For building the journey itself, see [Build, simulate, and launch a campaign journey](https://docs.orbit.devotel.io/guides/campaign-journey-builder.md). For the hardening checklist every production webhook consumer should pass, see [Webhook consumer production checklist](https://docs.orbit.devotel.io/guides/webhook-consumer-production-checklist.md).

## 1. Journey webhook node vs. organization event webhook

Orbit has two ways to push data to your endpoint, and they are not interchangeable:

| | Journey `webhook` node | Organization event webhook |
| - | - | - |
| Scope | One node inside one journey graph | The whole organization |
| Fires when | A contact traverses that node | Any subscribed event happens, whether or not a journey is running |
| Payload | Journey and contact context: which journey, which node, which contact, when | The event catalog payload for the subscribed event type |
| Configured | Per node: `webhookUrl` + `method` in the journey canvas | Organization-wide endpoint plus event subscriptions |

Pick the journey node when the fact that matters is "this contact reached this step of this journey." The call is journey-scoped and per-contact, and it sits inside the graph, so it participates in branches and waits: put the node after a condition and only contacts who passed the condition trigger the call.

Pick an organization event webhook when your system needs the event regardless of any journey, or when you need the broader event catalog (message statuses, conversation events, and the rest of the catalog documented in [Webhook event catalog](https://docs.orbit.devotel.io/guides/webhook-event-catalog.md)). Organization webhooks fire whether or not a journey is involved; the journey node fires only for contacts actually walking the graph.

## 2. Configuring the node

In the journey canvas, the Webhook node takes two settings:

* **`webhookUrl`** (required): the HTTPS endpoint to call. The URL is validated when the journey is saved: it must be `https`, and URLs that point at localhost, private ranges, link-local ranges, or cloud-metadata addresses are rejected with a validation error naming the node.
* **`method`** (optional, default `POST`): one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. A method outside the allowlist is rejected at save.

Validation runs twice, and the two layers are not the same check:

1. **Save time.** The URL string is validated when the journey definition is saved, so an obviously unsafe URL never persists.
2. **Send time.** When a contact reaches the node, the URL is validated again, this time against the addresses the hostname actually resolves to, immediately before the call is made. This closes the gap where a hostname that was public at save time later resolves to an internal address.

If the send-time validation rejects the URL, the call for that contact is skipped and the journey moves on. Your endpoint simply never sees the request; Orbit logs the rejection server-side. Design URLs that stay public and resolvable for the life of the journey.

One practical note on method: the payload travels as a JSON request body (`Content-Type: application/json`). POST, PUT, PATCH, and DELETE can carry that body. A GET request cannot, so a node configured with `method: "GET"` cannot deliver the payload; the call is recorded as failed server-side and skipped. Leave the default POST unless your endpoint explicitly expects PUT, PATCH, or DELETE.

## 3. The payload contract

Every delivery sends exactly this JSON body:

```json theme={null}
{
  "campaign_id": "cmp_9f2e1a",
  "journey_name": "Welcome series — new signups",
  "node_id": "n_sync_crm",
  "contact_id": "ctc_51d3b7",
  "recipient": "+14155550142",
  "timestamp": "2026-11-04T09:14:07.381Z"
}
```

Field by field:

| Field | Type | Meaning |
| - | - | - |
| `campaign_id` | string | The journey campaign that fired the call. Stable for the life of the campaign. |
| `journey_name` | string | The name of the journey, so one endpoint serving many journeys can tell them apart. Resolved from the campaign's name, falling back to its title, falling back to an empty string if neither is set. |
| `node_id` | string | The id of the webhook node that fired. Stable per node in the graph, so two webhook nodes in the same journey are distinguishable. |
| `contact_id` | string | The internal id of the contact traversing the node. Use it to look the contact up via the API. |
| `recipient` | string | The contact's destination address for this traversal (for example their phone number or email address). |
| `timestamp` | string | ISO-8601 UTC time at which the call was sent. This is send time, not enrollment time; it tells you when the contact reached the node. |

Two contract details worth coding against:

* **`journey_name` can be empty.** A journey whose campaign has neither a name nor a title sends `""`. Never branch on `journey_name` alone; pair it with `campaign_id`, or treat the empty string as an explicit case.
* **The shape is fixed.** The six fields above are the whole payload. If you need more contact context, fetch it by `contact_id` from the API rather than expecting extra fields to appear.

## 4. Worked consumer: one endpoint, three journeys

A common topology is a single endpoint serving every journey in the organization, branching on `journey_name` and `node_id`. The example below verifies the timestamp, dedupes, responds 2xx immediately, and does the real work off the request path:

```js theme={null}
// express >= 4, body parsed with express.json()
const WINDOW_MS = 5 * 60 * 1000; // accept deliveries up to 5 minutes old
const seen = new Set(); // in production: a durable store with TTL, not a Set

app.post("/orbit/journey-webhook", express.json(), (req, res) => {
  const {
    campaign_id: campaignId,
    journey_name: journeyName,
    node_id: nodeId,
    contact_id: contactId,
    recipient,
    timestamp,
  } = req.body ?? {};

  // 1. Respond fast, unconditionally, once input is parsed.
  res.status(200).json({ ok: true });

  // 2. Everything else happens off the request path.
  setImmediate(() => {
    // Reject stale or unparsable timestamps (replays, redeliveries from
    // before a rename, clock issues on your side).
    const age = Date.now() - Date.parse(timestamp);
    if (!Number.isFinite(age) || age < 0 || age > WINDOW_MS) {
      console.warn("stale journey webhook", { campaignId, nodeId, timestamp });
      return;
    }

    // Dedupe. A contact can traverse the same node twice (loops, waits),
    // so (campaign, node, contact) is the at-most-once-processing key.
    const key = `${campaignId}:${nodeId}:${contactId}`;
    if (seen.has(key)) return;
    seen.add(key);

    // Branch on journey + node.
    if (journeyName === "Welcome series — new signups" && nodeId === "n_sync_crm") {
      queue.enqueue("crm.sync", { contactId, recipient });
    } else if (journeyName === "Cart recovery" && nodeId === "n_notify_orders") {
      queue.enqueue("orders.recover", { contactId });
    } else if (journeyName === "Winback" && nodeId === "n_alert_support") {
      queue.enqueue("support.winback", { contactId, recipient });
    } else {
      // Unknown journey or node: log, don't crash. New journeys can go
      // live while this endpoint is running.
      console.warn("unrouted journey webhook", { journeyName, nodeId });
    }
  });
});
```

The load-bearing choices in that example are the ones every consumer should make:

* **Respond 2xx inside the 10-second budget** (below) and queue the real work. Any processing you do before responding counts against Orbit's timeout.
* **Deduplicate on `(campaign_id, node_id, contact_id)`** because re-traversal is legal.
* **Branch defensively**: an endpoint that serves several journeys will eventually receive a journey it does not know about yet.
* **Verify the timestamp window** so a replayed or very old delivery cannot re-trigger processing.

For richer examples of payload-driven consumers, see [Webhook payload cookbook](https://docs.orbit.devotel.io/guides/webhook-payload-cookbook.md).

## 5. Semantics you must plan for

The node is fire-and-forget by design. Plan for each of these:

* **No retries and no dead-letter queue.** If the call fails, times out, or is skipped, Orbit logs it server-side and the contact continues down the journey as if the call succeeded. There is no second attempt and no queue to drain later. If a hand-off is business-critical, make your endpoint durable on the first attempt (accept, persist, process asynchronously) and monitor your own inbound logs for gaps.
* **A 10-second response budget.** Each call carries a 10-second timeout. An endpoint that responds in 10.001 seconds is a failed call, identical to a 5xx from the node's point of view.
* **Pausing aborts in-flight calls.** Pausing the campaign aborts webhook calls that are in flight at that moment. If you were mid-burst when an operator paused the journey, expect some contacts' calls to be aborted rather than delivered; the journey resumes from where it paused, and those contacts continue (their aborted calls are not retried).
* **SSRF-rejected URLs are skipped silently from your side.** A URL that fails the send-time safety validation, is `http` rather than `https`, or resolves to a private, link-local, or metadata address is never dialed. Your endpoint sees nothing; the skip is visible only in Orbit's server-side logs. If deliveries for one journey stop entirely while other journeys keep arriving, check that the journey's URL is still public and still `https`.
* **Duplicates are normal.** A contact that re-enters a node, after a wait or a loop, produces another delivery with the same `(campaign_id, node_id, contact_id)` and a new `timestamp`. Design for at-least-once arrival with consumer-side dedup, not exactly-once delivery.
* **Journey webhook calls are not signed.** The payload carries no signature header. If you need to authenticate the sender, gate at the network layer (allowlist Orbit's egress ranges, or put a shared secret in the URL you configure on the node), or use organization event webhooks, which support signature verification, for anything that must be provably from Orbit. See [Verifying webhook signatures](https://docs.orbit.devotel.io/guides/verify-webhook-signatures.md).

## 6. Testing loop before launch

Test the pairing before any contact walks the graph live:

1. **Dry-run the journey in the simulator first.** The simulator walks test contacts through the graph and shows per-node behavior, including webhook nodes, before launch. See [Campaign journey simulation and inspection](https://docs.orbit.devotel.io/guides/campaign-journey-simulation-inspection.md).
2. **Point the node at the webhook tester or a sandbox sink.** Capture the exact body your endpoint will receive, and assert your parser accepts all six fields, including an empty `journey_name`. See [Webhook tester](https://docs.orbit.devotel.io/guides/webhook-tester.md).
3. **Enroll a single test contact** in the sandbox and watch the delivery arrive end to end, including your dedup and timestamp-window paths (send the same contact through twice on purpose).
4. **Load-test your response time.** Your p99 must sit comfortably inside the 10-second budget with headroom, because anything above it is a silently failed hand-off.

## 7. Troubleshooting

| Symptom | Cause | What to do |
| - | - | - |
| Your endpoint returns 5xx for some deliveries | The call is recorded as failed; there is no retry and the contact continued anyway. | Fix the endpoint, then re-test with a fresh test contact. Check your inbound logs for the window in which 5xxs occurred; those hand-offs already need manual recovery if they were business-critical. |
| Deliveries arrive for a while, then stop, with no errors on your side | Responses started exceeding the 10-second budget, so calls are timing out. | Return 2xx immediately and move work off the request path (section 4). |
| A journey's deliveries stop entirely while other journeys still arrive | The URL now fails the send-time safety validation (renamed host resolving to a private address, downgraded to `http`, or expired TLS). | Restore a public `https` URL on the node and re-save the journey; re-test with a single contact. |
| `journey_name` arrives empty for some journeys | The campaign behind that journey has neither name nor title set, so the field falls back to `""`. | Branch on `campaign_id` for that journey, or set the campaign's name. |
| The same contact triggers your endpoint twice | The contact traversed the node twice (a loop or a wait re-entry). This is expected, not a defect. | Dedup on `(campaign_id, node_id, contact_id)`, or deliberately process each traversal if re-entry is meaningful for your use case. |
| You set `method` to `GET` and nothing ever arrives | The payload is a JSON request body, which a GET request cannot carry; the call fails server-side and is skipped. | Switch the node back to the default `POST` (or `PUT`/`PATCH`/`DELETE`). |
| You cannot prove a delivery came from Orbit | Journey webhook calls carry no signature. | Gate at the network layer, or use organization event webhooks with signature verification for authenticated events. |

## Related

* [Build, simulate, and launch a campaign journey](https://docs.orbit.devotel.io/guides/campaign-journey-builder.md): the canvas, node vocabulary, and save-time validation errors.
* [Webhook consumer production checklist](https://docs.orbit.devotel.io/guides/webhook-consumer-production-checklist.md): the general hardening list for any endpoint that receives Orbit webhooks.
* [Webhook payload cookbook](https://docs.orbit.devotel.io/guides/webhook-payload-cookbook.md): payload-handling patterns across Orbit's webhook surfaces.
* [Campaign journey simulation and inspection](https://docs.orbit.devotel.io/guides/campaign-journey-simulation-inspection.md): dry-run a journey, webhook nodes included, before launch.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.