Webhooks

Orbit's webhooks facilitate real-time data provision, allowing your application to respond to events as they transpire, without the need for constant polling for changes. This results in a more efficient and often more effective system, reducing server strain.

Consider these webhook events as a reverse API call. Rather than your application requesting data from our API, Orbit pushes the data directly to your application as the specified event takes place.

Orbit's webhooks can be set up to notify about a variety of events. These could include the creation of a new tour, the completion of a tour, or a new shipper registration. By subscribing to these events, your application can automate tasks, align data, or even interact with other systems, thereby streamlining your processes.

You can set up webhooks in Orbit through MissionControl under Settings -> Webhooks, or programmatically with your API key through the Webhooks endpoints: POST /webhooks registers one (target URL, events, optional per-event filters, authentication), GET /webhooks and GET /webhooks/{webhookId} read them back with every secret masked, and DELETE /webhooks/{webhookId} removes one. The request shape and validation are the same on both surfaces; there is no update endpoint — delete and re-create to change a webhook.

Authorization

Webhook authorization ensures that incoming requests to your endpoints are genuinely from Orbit, protecting your application from unauthorized or malicious requests. This security layer prevents data tampering and maintains the integrity of your webhook integrations.

Orbit supports three authentication methods for webhook delivery. Whichever you choose, the secret is stored write-only: once saved it is never shown or returned again, and editing a webhook without retyping it keeps the stored value. To change a secret, enter the new one.

Basic Authentication

Basic authentication uses a username and password combination that you configure when setting up your webhook. Orbit includes these credentials in the standard HTTP Authorization header using Base64 encoding. This method provides straightforward security and is widely supported across platforms and frameworks.

Bearer Token Authentication

Bearer token authentication sends a token you configure in the standard HTTP Authorization header:

HeaderValue
AuthorizationBearer <your-token>

The token travels with every delivery, so use it only over HTTPS. It suits endpoints that already accept an API token, and it is the simplest option to verify: compare the received token against the one you configured. For a secret that never crosses the network at all, use HMAC instead.

HMAC Authentication

HMAC (Hash-based Message Authentication Code) provides cryptographically secure authentication using a shared secret. This method offers superior security as the secret never travels over the network.

HMAC uses the HMAC-SHA256 algorithm with your configured secret key. The signed payload always includes the epoch timestamp in seconds and is dot-separated with the stringified JSON body (timestamp.raw_json_body). The signature is encoded in lowercase hexadecimal format.

Headers set by HMAC Authentication:

HeaderValue
X-Signature-Algorithmhmac-sha256
X-Signature-TimestampUnix epoch seconds
X-SignatureThe computed HMAC signature

To verify authenticity, recreate the signature using your secret and compare it with the received signature. Most applications implement a replay window (typically 5 minutes) to prevent replay attacks by rejecting requests with timestamps outside the acceptable range. HMAC authentication is recommended for production environments requiring enhanced security.

Delivery & Timeouts

Each event is delivered as an HTTP POST with a JSON body and an X-Delivery-Id header. The delivery ID identifies one delivery, that is one event for one webhook, and stays the same across every attempt of that delivery. Use it to deduplicate on your side: a delivery can reach you more than once, for example when your endpoint processed a request but answered too late.

Orbit waits up to 20 seconds for your endpoint to respond. Acknowledge receipt quickly and handle longer processing asynchronously.

Retries

A delivery counts as successful when your endpoint answers with a 2xx status. Orbit retries a delivery when your endpoint answers with a 5xx status or with 429, when it does not answer within 20 seconds, or when it cannot be reached. Any other status, for example 400, 401 or 404, ends the delivery at once, because repeating the same request would not change the answer.

A delivery gets up to 6 attempts over about 24 hours. After the first attempt the waiting times are 1 minute, 10 minutes, 1 hour, 6 hours and 16 hours. Every attempt sends the same body and the same X-Delivery-Id. Authentication is computed again for each attempt, so with HMAC the X-Signature-Timestamp and the X-Signature differ between attempts. A retry uses the webhook as it is configured at that moment: a changed target URL or changed credentials apply to the next attempt.

Retries do not preserve order. While one delivery waits for its next attempt, newer events for the same entity are delivered right away, so a retried delivery can arrive after a newer one. The payload carries the state of the entity at the time of the event.

Automatic disabling

When every delivery to a webhook has failed for three days, Orbit disables the webhook. The three days start when a delivery has failed for good, that is after its last attempt, and a single successful delivery resets them. A disabled webhook receives no further events, and deliveries that were still waiting for a retry are discarded.

When a webhook is disabled, Orbit sends an email: to the user who created the webhook, or, if it was created with an API key or that user is no longer active, to the alert recipients configured for your account, and otherwise to your administrators.

The webhook object shows the state in three fields: failingSince (when the current run of failures began; absent while the webhook is healthy), disabledAt and disabledReason (both absent while the webhook is enabled). You can read them through GET /webhooks and GET /webhooks/{webhookId}.

A disabled webhook is re-enabled in MissionControl under Settings -> Webhooks. There is no API endpoint for it; through the API you can delete the webhook and create it again.

Catching up on missed events

Orbit does not store delivery payloads and cannot send a delivery again once its attempts are used up or it was discarded. After an outage, bring your system up to date by reading the changes through the list endpoints of the API, using their updatedSince filter where it is available, instead of waiting for the missed events.

Filtering Deliveries

Each event subscription can optionally carry a filter — a set of conditions evaluated against the event's payload before delivery. When a filter is set, Orbit only sends the webhook if the payload matches; non-matching events are skipped silently without an HTTP request. This lets you subscribe to a busy event type while receiving only the deliveries you care about, e.g. tour-completed only for tours of a specific region, or shipment-updated only when the shipment has reached a specific status.

Filters are configured per event in MissionControl under Settings -> Webhooks: enable an event, then choose Add Filter on its row. The available fields are derived from that event's payload (the same shape documented in the table below, using dotted paths such as tour.status), so a filter can only reference data the delivery actually contains.

Your own custom properties — the ones created in MissionControl under Settings -> Properties — are filterable too. A property appears as the field <entity>.properties.<propertyId> on events that deliver the whole entity it is defined on, so a property on Tour can be filtered on tour-completed while a property on Order can be filtered on order-updated (id-only events such as tour-deleted offer no property fields). Text, number, checkbox and select properties are filterable today; multiselect, tags and document properties are listed in the field picker but cannot be selected yet. Select conditions compare against the option's id, and the picker inserts it for you when you choose the option by name. On order-created, fields of the optional tour payload entity offer no "is set" / "is not set" conditions — an order without a tour has no tour entity to check them against.

Two events deliver a list rather than a single entity: shipments-created carries an array of shipments and transport-drafts-created an array of transport drafts, so neither offers per-shipment or per-draft filter fields yet — filter shipment-updated instead when you need shipment-level conditions.

A few semantics worth knowing:

  • Conditions within a group combine with AND by default (every condition must match); a group can be switched to OR (any condition may match), and groups can be nested.
  • Text conditions (contains, begins with, ends with, equals) match literally and case-sensitively. Each has a negated form (does not contain, does not begin with, does not end with, does not equal).
  • A negated text condition also matches when the field carries no value. A custom property that was never set, or an optional payload field the event omits, is not equal to anything and contains nothing, so does not equal X delivers those events as well.
  • Date conditions compare against calendar days anchored at Europe/Berlin.
  • A skipped delivery is not a failure: it does not consume retries and never triggers failure alerting. Your endpoint simply doesn't hear about the event.
  • A subscription without a filter behaves exactly as before — it fires for every occurrence of the event.

Types and Payloads

In the subsequent table, we provide a description of each webhook event type and the corresponding payload format.

Event TypePayload
tour-created{tour: Tour}
tour-assigned{tour: Tour}
tour-unassigned-carrier{tour: Tour}
tour-started{tour: Tour}
tour-arrived-at-stop{tour: Tour}
tour-departed-from-stop{tour: Tour}
tour-proof-attached{tour: Tour}
tour-waiting-for-review{tour: Tour} — fired by the last advance, by a carrier-reported delivery, or by manual-complete
tour-completed{tour: Tour}
tour-cancelled{tour: Tour}
tour-deleted{tourId: String}
tour-edited{tour: Tour}
tour-rebuilt{tour: Tour, action: TourAction}
tour-action-created{tour: Tour, action: TourAction, oldStatus?: String, newStatus?: String, deviceTime: Number, receivedAt: Number, conflict: Boolean}
shipments-created{shipments: Shipment[]}
shipment-deleted{shipmentId: String}
shipment-updated{shipment: Shipment}
order-created{order: Order, tour?: Tour, tours?: Tour[], documentLinks?: DocumentLink[]}
order-updated{order: Order}
operator-user-invite-created{operatorUserInvite: OperatorUserInvite}
operator-user-created{operatorUser: OperatorUser}
shipper-created{shipper: Shipper}
shipper-updated{shipper: Shipper, action: ShipperAction}
shipper-team-created{shipperTeam: ShipperTeam}
shipper-team-updated{shipperTeam: ShipperTeam, action: ShipperTeamAction}
shipper-user-created{shipperUser: ShipperUser}
shipper-user-updated{shipperUser: ShipperUser, action: ShipperUserAction}
shipper-address-created{shipperAddress: ShipperAddress}
mailbox-created{mailbox: Mailbox}
mailbox-mail-received{mailboxId: String}
mailbox-status-changed{mailboxId: String, newStatus: "active" | "inactive", oldStatus: "active" | "inactive"}
decode-job-result{transportDraft: TransportDraft}
callout-action-created{callouts: CalloutWithStats[], action: CalloutAction}
sequence-invocation-created{sequenceInvocation: SequenceInvocation}
sequence-invocation-action-created{sequenceInvocationAction: SequenceInvocationAction}
transport-drafts-created{transportDrafts: TransportDraft[]}
transport-drafts-deleted{transportDraftIds: String[]}
transport-draft-action-created{transportDraft: TransportDraft, action: TransportDraftAction}
carrier-action-created{carrier: Carrier, action: CarrierAction}
carrier-team-created{carrierTeam: CarrierTeam}
carrier-team-action-created{carrierTeam: CarrierTeam, action: CarrierTeamAction}
carrier-user-created{carrierUser: CarrierUser}
carrier-user-action-created{carrierUser: CarrierUser, action: CarrierUserAction}
document-link-action-created{documentLink: DocumentLink, action: DocumentLinkAction}
leg-created{leg: Leg}
leg-action-created{leg: Leg, action: LegAction}
leg-deleted{leg: Leg}
transport-draft-recurring-activated{transportDraft: TransportDraft}
transport-draft-recurring-paused{transportDraft: TransportDraft}
transport-draft-recurring-resumed{transportDraft: TransportDraft}
transport-draft-recurring-ended{transportDraft: TransportDraft}
comment-created{comment: Comment}
comment-edited{comment: Comment}
comment-deleted{comment: Comment}
comment-reaction-added{comment: Comment, reaction: CommentReaction}
comment-reaction-removed{comment: Comment, reaction: CommentReaction}
task-created{task: Task, referencedEntity: {orn: String, type: String, id: String}, deviceTime: Number, receivedAt: Number}
task-completed{task: Task, referencedEntity: {orn: String, type: String, id: String}, deviceTime: Number, receivedAt: Number}
task-skipped{task: Task, referencedEntity: {orn: String, type: String, id: String}, deviceTime: Number, receivedAt: Number}
task-cancelled{task: Task, referencedEntity: {orn: String, type: String, id: String}, deviceTime: Number, receivedAt: Number}

Notes on order-created

  • An order can create several tours at once: one for every shipment group of the request that asks for a tour, or one per shipment when it is booked that way. tours lists all of them in creation order, each in the same shape as tour.
  • tour is always the first entry of tours, so an integration that reads only tour keeps working for orders with a single tour. Read tours to learn about every tour.
  • Both fields are absent when the order created no tour.
  • Delivery filters address tour only. tours is a list, and filter fields cannot address list entries.

Notes on tour-action-created

  • Fires for every action on a tour — status advances, reversals (reverse), resets (reset), manual completions (manual-complete), assignments, edits, rebuilds, proof attachments, and more — making it the one subscription that observes the tour's complete activity stream, including backwards status transitions that no lifecycle event covers.
  • action.payload.type identifies the action. Combine this event with a delivery filter (see "Filtering Deliveries" above) on action.payload.type to receive only the actions you care about, e.g. only reverse and reset.
  • deviceTime is when the action happened, in epoch seconds: the time the driver's device or the calling system recorded it, corrected for a wrong device clock and never later than receivedAt, the time the platform received it. Stop and delivery times on the tour follow deviceTime. A receivedAt well after deviceTime means the action was reported late, for example after the device was offline. The individual tour-* lifecycle events keep their payload unchanged; their tour snapshot carries the same stop times.
  • oldStatus / newStatus carry the tour's status before and after the action. They are only present when the originating commit relayed its status snapshot onto this action (the first action of a commit); when present and equal, the action did not change the tour's top-level status (the change may be in tour.substatus).
  • For advance actions, action.payload.capturedLocation is the optional device location and accuracy recorded for that advance. Load status updates carried by a departure use the same location.
  • conflict is true when the action is a clarification case: a step reported from the driver app (an advance or a single execution step) does not fit the tour, for example a stop that was removed meanwhile, two devices reporting different values, a departure without its mandatory proofs, or a report from before the tour was reassigned. The step is recorded and the driver is never held up; action.outcome says what happened (kind, reasons, alternatives). For every clarification case the platform opens a task with origin: "conflict" for the operators, whose referencedEntity is the action (type: "action", id: the action's clientActionId, ORN orn:<tenant-id>:tour/<tourId>/action/<clientActionId>); its task-created and task-completed webhooks follow the usual task events. A tour with an open conflict task cannot be reviewed: the review is refused with 409 and the error code OPEN_CONFLICT_TASKS until the task is completed or cancelled. Automations keep running on a clarification case; filter on conflict to single it out.
  • Prefer this event over the individual tour-* lifecycle events for new integrations that mirror tour state — the lifecycle events remain supported, but new action types are surfaced here only.
  • Carrier-integration internals are redacted from the delivered action: raw carrier-provider payloads (e.g. on system-generated carrier status updates) are stripped, and any internal carrier-booking record is reduced to its bounded identifiers (provider, carrierId, carrierBookingId, trackingRef) with integration internals removed — a policy analogous to the bounded carrier-booking view exposed on the tour object itself. System-stamped internal routing fields (such as the pre-assign carrier reference on assign actions) are likewise omitted, matching the action shape of the REST API.

Notes on callout-action-created

  • callouts contains only the callouts the action actually modified (e.g. a decline carries just the declining carrier's callout; creating callouts carries the newly activated ones) — it is not the cluster's full callout list.
  • Each callout is enriched with its cluster's bidding stats (currentBid, bidCount, isWinning, suggestedBid). For secret auctions only suggestedBid is included — bid state is never revealed.
  • action.payload mirrors the request that drove the transition; internal dispatch context (such as system cancel reasons) is stripped.
  • tour carries the id and custom properties of the tour the callouts are for, so you can filter this event on tour.properties.<propertyId> — for example to receive only the callout activity for one shipper. Filtering on the callouts themselves is not possible: callouts is a list, and filter fields cannot address list entries.
  • tour is deliberately only id and properties, not the whole tour: everything else a tour carries may belong to other customers loaded on the same trip. Read the tour from GET /tours/{id} when you need more than the properties.
  • tour is absent when the action was not raised against a tour, which happens on system cancellations. A condition on a tour field does not match such a delivery, so a filter narrows rather than leaks.
  • action.createdAt and action.createdAtMilliseconds are both epoch milliseconds — an explicit exception to the seconds rule in Units & formatting. The two fields carry the same value, and it matches the timestamp stored on the action record, so an event value can be compared directly against one read back from the API.
  • Compatibility note: before this change action.createdAt was delivered in epoch seconds while the stored record already held milliseconds, so the two disagreed by a factor of 1000. Deliveries sent before the change carry the old seconds value. If you persisted or compared action.createdAt from earlier deliveries, treat any value below 1000000000000 as seconds and multiply by 1000. action.createdAtMilliseconds was always milliseconds and is unaffected.

Notes on leg events

  • There are no per-status leg events: every lifecycle transition — pickup and dropoff progress, delivery, failure, cancellation, tour assignment — is delivered as leg-action-created. The transition verb is identified by action.payload.type, and the payload always carries the full leg as it looks after the transition.
  • Two leg loads can end without arriving, and they mean opposite things. Returned means this shipment's obligation for the load moved to a return shipment, so the goods are intact and travel on under that shipment. Perished means the goods were written off: they no longer exist as deliverable cargo, and the leg load then carries the write-off reason (Lost or Damaged), an optional note, and who wrote it off. The reason says why the goods stopped being deliverable, never what was done with them afterwards: damaged goods stay in the operator's location stock as dead stock until their disposal is recorded, and the leg load then also carries disposedAt and disposedBy. A Lost load never carries them. A returned load leaves the shipment un-failed because the obligation lives on elsewhere; a perished load fails it. If you classify deliveries, treat Perished as a terminal loss and do not wait for a follow-up shipment: none is created.
  • Unlike tour-deleted and shipment-deleted, which deliver only the deleted entity's id, leg-deleted carries the full removed leg — a deleted leg can no longer be fetched from the API, so its final state is included in the delivery.

Notes on task events

  • The four task-* events cover the complete task lifecycle: task-created when a task comes to exist, and exactly one of task-completed, task-skipped, or task-cancelled when it closes. Reassignments produce no event.
  • The task object is the task as it looks after the transition. On task-completed it carries the complete validated form output (task.outcome.formOutput, keyed by form element id) along with the task's kind, assignee, origin, and timestamps. A task created from a template carries the template's id in task.templateId and, when the template has one, its stable routing label in task.templateKey; branch on templateKey to route completions without dereferencing the template.
  • Rendered receipts arrive separately. When a task was created from a template that names a documentTemplateId, a PDF is rendered from that document template after the task completes and linked to the task (and to its referenced entity). The task-completed payload does not change and does not wait for the render; subscribe to document-link-action-created and filter on documentLink.targetEntityType === "task" to pick the receipt up, or list the task's documents.
  • referencedEntity is a reference, not the object. Task payloads carry \{orn, type, id} parsed from the task's ORN and never embed the referenced Tour, Shipment, or other entity. Fetch the current state of the referenced entity through the API when you need it — an embedded copy would already be stale by the time your endpoint processes the delivery.