Webhooks
Overview
Webhooks notify your system in real time when something happens to an appointment in Conduit: it's booked, confirmed, checked in, rescheduled, cancelled, and so on. This is useful for keeping a TMS, WMS, ERP or other system in sync with appointment status, check in and check out times, dock assignments and other appointment data.
Each webhook is a small JSON notification sent over HTTPS. It tells you which appointment changed and what kind of change it was. It does not include the appointment's data. To get the data, call the Conduit API when you receive the event.
Custom webhooks
This guide describes Conduit's default webhooks. Conduit can also support custom adapters that send webhooks in a different format, such as a payload that includes appointment data, a specific schema, or a different authentication method. If your system needs something different, contact us at [email protected] to discuss.
How it works
- Something happens to an appointment, such as a driver checking in.
- Conduit sends a
POSTrequest to your endpoint with the event. The event contains the appointment's ID, not the appointment itself. - Your endpoint verifies the signature, records the event and returns a
200. - Your system fetches the appointment from Get FacilityAppointment using the
facility_appointmentID in the event, and updates its own records from the response.
Why fetch the appointment instead of reading it from the event?
Appointments often change several times in quick succession. For example, a check in, dock assignment and load start can happen within a minute of each other. If each event carried a copy of the appointment, a delayed or retried event could overwrite newer data with older data. Fetching the appointment always gives you its current state, so it doesn't matter what order events arrive in or whether one is delivered twice.
This means:
- Treat the event as a signal to refresh, not as the change itself. Read
status, times, dock, references and other fields from the fetched appointment. - The fetched appointment may already reflect later changes. If you receive
APPOINTMENT_CHECK_INand fetch the appointment a moment later, it may already be checked out. - To find out what changed, compare the fetched appointment with your stored copy. The event doesn't say which fields changed.
Payload
{
"id": "6f1c2a7e-9b3d-4c1f-8e2a-5d7b9c0e1f23",
"facility": "2aae24c7-438d-473f-aba8-7793b7efdec3",
"facility_appointment": "0bc8eae3-2e91-43ef-bd4a-164c76618942",
"created_at": "2025-01-08T15:04:05.123456+00:00",
"event_category": "APPOINTMENT_CHECK_IN"
}
| Field | Description |
|---|---|
id |
Unique ID of the event. Use it to ignore duplicate deliveries. |
facility |
ID of the Facility the appointment belongs to. |
facility_appointment |
ID of the FacilityAppointment. Pass it as id to Get FacilityAppointment. |
created_at |
When the event happened, as an ISO 8601 date-time in UTC. This is not when it was delivered. |
event_category |
What happened. See Event categories. |
Event categories
Conduit sets which event categories your endpoint receives when we set up your webhooks. Let us know if you only want some of them.
One action can produce more than one event. For example, editing an appointment's dock and its scheduled time together sends both APPOINTMENT_INFO_UPDATED and APPOINTMENT_FACILITY_RESCHEDULED.
Booking and confirmation
| Event | Sent when | Status afterwards |
|---|---|---|
APPOINTMENT_REQUESTED |
A carrier requests an appointment on the Conduit scheduling page or through Request FacilityAppointment, and the facility must approve it. | Requested |
APPOINTMENT_CREATED |
An appointment is booked without needing approval. This happens when: - facility staff book it in Conduit - a carrier books it on the scheduling page or through the API at a facility that confirms bookings automatically - a connected integration creates it - facility staff rebook an appointment to another facility (the event is for the new appointment) Appointments generated from a recurring series don't send this event. |
Scheduled |
APPOINTMENT_CONFIRMED |
A requested appointment is confirmed. This happens when: - facility staff approve a request - a reschedule request is approved by the facility or the carrier When a reschedule is approved, the scheduled time has changed, so re-read scheduled_start_time and scheduled_end_time. |
Scheduled |
APPOINTMENT_DECLINED |
Facility staff decline a requested appointment or work-in. | Request Declined |
WORK_IN_CREATED |
Facility staff add a work-in for a truck that is already on site. The appointment is created already checked in, so no separate check in event is sent. | Checked In |
Check in and check out
| Event | Sent when | Status afterwards |
|---|---|---|
DRIVER_CHECKED_IN |
The driver checks themselves in at a kiosk or on their phone. If the driver adds a work-in that wasn't booked, this is the first event you'll receive for that appointment. Its status is Requested until the facility confirms or declines it. |
Checked In |
APPOINTMENT_CHECK_IN |
Facility staff check the appointment in, change its check in time, or undo the check in. | Checked In, or the previous status if undone |
APPOINTMENT_CHECKED_OUT |
Facility staff check the appointment out, change its check out time, or undo the check out. | Checked Out, or the previous status if undone |
Undoing a check in or check out sends the same event as the original action. To tell them apart, check whether checked_in_at or checked_out_at on the fetched appointment is empty.
Changes to an appointment
| Event | Sent when |
|---|---|
APPOINTMENT_INFO_UPDATED |
Details on the appointment change. This includes: - dock or pick trailer assignment - notes - carrier, shipper, receiver, booker or customer details - driver, tractor, trailer and seal details - quantity and units - references and orders - load types - tags - custom fields Changes can come from facility staff, from the carrier on the scheduling page, from Update FacilityAppointment Order, from Update FacilityAppointment Customer, or from a connected integration. Some actions, like assigning a dock, send this event even if the value didn't change. |
APPOINTMENT_FACILITY_RESCHEDULED |
Facility staff move a scheduled appointment to a new time. |
APPOINTMENT_CARRIER_RESCHEDULED |
A carrier moves an appointment to a new time on the scheduling page at a facility that confirms reschedules automatically. If the facility must approve the new time, you'll receive APPOINTMENT_CONFIRMED once it's approved instead. |
APPOINTMENT_ETA_UPDATED |
A carrier updates the appointment's ETA on the scheduling page or through Update FacilityAppointment ETA. It is sent even if the ETA didn't change. ETA changes made by facility staff send APPOINTMENT_INFO_UPDATED instead. |
ETA_UPDATE_REQUESTED |
Conduit emailed the carrier asking for an ETA, because the appointment is past its scheduled time with no check in and no ETA. |
APPOINTMENT_ATTACHMENT_CREATED |
A file is attached to the appointment by facility staff, the carrier, the driver over chat, or Conduit, such as a signed document. Files uploaded during driver check in or with a booking request don't send this event. Use List FacilityAppointment Attachments to get the files. |
APPOINTMENT_ATTACHMENT_DELETED |
A file is removed from the appointment. |
Cancellation
| Event | Sent when | Status afterwards |
|---|---|---|
APPOINTMENT_CANCELLED |
The appointment is cancelled by facility staff, through Cancel FacilityAppointment, by the carrier on the scheduling page, or by a connected integration. | Cancelled or Carrier Cancelled |
APPOINTMENT_CARRIER_CANCELLED |
The carrier cancels the appointment. | Carrier Cancelled |
APPOINTMENT_LOAD_REJECTED |
Facility staff reject the load. This can happen after check in. | Load Rejected |
APPOINTMENT_AUTO_CANCELLED |
Conduit cancels a no-show appointment using the facility's auto-cancel rules, or a connected integration removes the shipment. | Auto Cancelled |
To find out why an appointment was cancelled, read cancelled_reason and cancelled_note on the fetched appointment rather than relying on the event category.
Setup
- Create an
HTTPSendpoint that acceptsPOSTrequests with aJSONbody and returns a200. - Test the endpoint with the sample payload above.
- Deploy the endpoint so that it is publicly accessible.
- Contact us at [email protected] with your endpoint URL and the event categories you want. We'll send you your signing secret.
Receiving events
Return a 200 as soon as you've verified and recorded the event, before doing any other work like fetching the appointment or updating your database. Conduit waits up to 45 seconds for a response.
Conduit only treats a 200 as success. Any other response, including other 2xx codes like 201 or 204, is treated as a failure and retried.
We recommend handling each event like this:
- Verify the signature. Reject the request if it doesn't match.
- If you've already processed an event with the same
id, return200and stop. - Store the event or put it on a queue, then return
200. - In the background, fetch the appointment and update your system.
Retries and ordering
If your endpoint doesn't return a 200, Conduit retries the event a few times over the next several minutes, then keeps retrying periodically for up to 24 hours. After that the event is dropped.
Endpoints that fail consistently for 7 days will be turned off until we can get in contact with you.
Because of retries:
- You may receive the same event more than once. Use
idto ignore duplicates. - Events may arrive late or out of order. Use
created_atfor when the event happened, and fetch the appointment for its current state.
Verification
Each webhook includes a Conduit-Signature header so you can confirm it came from Conduit. The signature is an HMAC-SHA256 of the request, signed with your signing secret.
Conduit-Signature: t=1683607883;v0=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
How to verify
- Read the timestamp and signature from the header. Split the header on
;, then split each part on the first=.tis the Unix timestamp, in seconds, when the request was signed.v0is the signature. - Build the signed payload. Join the timestamp and the raw request body:
{t}{raw body}, with nothing in between. Use the body exactly as received. Parsing theJSONand serializing it again can change spacing and break the signature. - Compute the expected signature. Calculate an
HMAC-SHA256of the signed payload, using your signing secret as the key, and hex-encode it. - Compare the signatures. Compare the expected signature with
v0using a constant-time comparison. If they match, check thattis within your tolerance of the current time, for example 5 minutes, to protect against replayed requests.
Each retry is signed again with a new timestamp, so retried events still pass a timestamp check.
Examples
Python
import hashlib
import hmac
import time
def is_valid_conduit_signature(raw_body: bytes, signature_header: str, signing_secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(part.split("=", 1) for part in signature_header.split(";"))
timestamp = parts["t"]
signature = parts["v0"]
signed_payload = timestamp.encode() + raw_body
expected_signature = hmac.new(signing_secret.encode(), signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected_signature, signature):
return False
return abs(time.time() - int(timestamp)) <= tolerance_seconds
Node.js
const crypto = require("crypto");
function isValidConduitSignature(rawBody, signatureHeader, signingSecret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
signatureHeader.split(";").map((part) => {
const separatorIndex = part.indexOf("=");
return [part.slice(0, separatorIndex), part.slice(separatorIndex + 1)];
})
);
const expectedSignature = crypto
.createHmac("sha256", signingSecret)
.update(parts.t + rawBody)
.digest("hex");
const expected = Buffer.from(expectedSignature);
const received = Buffer.from(parts.v0 ?? "");
if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
return false;
}
return Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds;
}
Java
Requires Java 17 or later.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class ConduitWebhookVerifier {
public static boolean isValidConduitSignature(byte[] rawBody, String signatureHeader, String signingSecret, long toleranceSeconds) throws Exception {
Map<String, String> parts = new HashMap<>();
for (String part : signatureHeader.split(";")) {
String[] keyValue = part.split("=", 2);
if (keyValue.length == 2) {
parts.put(keyValue[0], keyValue[1]);
}
}
String timestamp = parts.get("t");
String signature = parts.get("v0");
if (timestamp == null || signature == null) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update(timestamp.getBytes(StandardCharsets.UTF_8));
String expectedSignature = HexFormat.of().formatHex(mac.doFinal(rawBody));
if (!MessageDigest.isEqual(expectedSignature.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8))) {
return false;
}
return Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) <= toleranceSeconds;
}
}
C#
Requires .NET 5 or later.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
public static class ConduitWebhookVerifier
{
public static bool IsValidConduitSignature(byte[] rawBody, string signatureHeader, string signingSecret, long toleranceSeconds = 300)
{
var parts = new Dictionary<string, string>();
foreach (var part in signatureHeader.Split(';'))
{
var keyValue = part.Split('=', 2);
if (keyValue.Length == 2)
{
parts[keyValue[0]] = keyValue[1];
}
}
if (!parts.TryGetValue("t", out var timestamp) || !parts.TryGetValue("v0", out var signature))
{
return false;
}
var signedPayload = Encoding.UTF8.GetBytes(timestamp).Concat(rawBody).ToArray();
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingSecret));
var expectedSignature = Convert.ToHexString(hmac.ComputeHash(signedPayload)).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expectedSignature), Encoding.UTF8.GetBytes(signature)))
{
return false;
}
return long.TryParse(timestamp, out var timestampSeconds)
&& Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestampSeconds) <= toleranceSeconds;
}
}

