Outbound Webhooks
Receive real-time HTTP notifications when events occur in the system.
Overview
Outbound webhooks allow you to receive real-time notifications via HTTP POST requests when vehicles and orders change status. Instead of polling the API for updates, you register a URL and we send event data to it automatically.
Each webhook request includes an HMAC-SHA256 signature so you can verify the request is authentic, a timestamp to prevent replay attacks, and a unique delivery ID for idempotency.
Authentication
All webhook management API requests require a valid Sanctum API token with the
PUBLIC_API
permission.
Include your token in the Authorization header:
Authorization: Bearer your-api-token
Quick Start
1. Register a webhook
POST /api/webhooks
Content-Type: application/json
Authorization: Bearer your-api-token
{
"url": "https://your-app.com/webhooks/carpool",
"events": ["vehicle.picked_up", "vehicle.delivered", "order.created"],
"description": "Production webhook"
}
The response includes a secret
field. Save this immediately — it is only returned once and is used to verify webhook signatures.
2. Send a test event
POST /api/webhooks/{webhook_id}/test
Authorization: Bearer your-api-token
This sends a test vehicle.created event to your URL so you can verify everything is working.
3. Verify the signature
Check the X-Webhook-Signature
header against the payload to confirm authenticity.
See Verifying Signatures for details.
API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/webhooks | List your registered webhooks |
| POST | /api/webhooks | Register a new webhook |
| GET | /api/webhooks/{id} | Get webhook details |
| PATCH | /api/webhooks/{id} | Update webhook (URL, events, status) |
| DELETE | /api/webhooks/{id} | Delete a webhook |
| GET | /api/webhooks/{id}/deliveries | List delivery history |
| POST | /api/webhooks/{id}/test | Send a test event |
| GET | /api/webhook-events | List all available event types |
Register a webhook
POST /api/webhooks
{
"url": "https://your-app.com/webhooks",
"events": ["vehicle.picked_up", "vehicle.delivered"],
"description": "My webhook"
}
// Response (201 Created)
{
"status": true,
"status_code": 201,
"data": {
"id": "a7k3m9x2n4p1q",
"url": "https://your-app.com/webhooks",
"secret": "kJ9mN2pR4...",
"events": ["vehicle.picked_up", "vehicle.delivered"],
"is_active": true,
"description": "My webhook",
"created_at": "2026-02-08T12:00:00+00:00"
}
}
Update a webhook
PATCH /api/webhooks/{id}
{
"url": "https://your-app.com/webhooks/v2",
"events": ["vehicle.picked_up", "vehicle.delivered", "order.created"],
"is_active": false
}
Delivery history fields
Each delivery from GET /api/webhooks/{id}/deliveries includes event_type, entity_type, status, attempt_count, response_status, delivered_at, and next_retry_at.
Available Events
| Event Name | Entity | Description |
|---|---|---|
| vehicle.created | Vehicle | A new vehicle has been added to an order |
| vehicle.picked_up | Vehicle | A vehicle has been picked up by the carrier |
| vehicle.delivered | Vehicle | A vehicle has been delivered to the destination |
| vehicle.canceled | Vehicle | A vehicle has been canceled |
| vehicle.pickup_inspection_completed | Vehicle | Pickup inspection has been completed (includes inspection details and photos) |
| vehicle.delivery_inspection_completed | Vehicle | Delivery inspection has been completed (includes inspection details and photos) |
| order.created | Order | A new order has been created |
| order.picked_up | Order | All active vehicles in the order have been picked up (aggregate event) |
| order.delivered | Order | All active vehicles in the order have been delivered (aggregate event) |
| order.canceled | Order | An order has been canceled |
order.picked_up and
order.delivered fire only when all active vehicles in
an order reach the corresponding status. Vehicles with exception statuses (hold, canceled, dry run) are
excluded from the calculation.
Payload Structure
Every webhook delivery sends an HTTP POST with a JSON body wrapped in a common envelope:
id— unique delivery identifier (also sent in theX-Webhook-Idheader)event— the event name (e.g.vehicle.picked_up)timestamp— ISO-8601 time the event was dispatchedentity— the entity that triggered the event, with itstype(VehicleorOrder) and slugiddata— the entity payload; its shape depends onentity.type(see the per-event examples below)
Vehicle event payload
Vehicle events (vehicle.created, vehicle.picked_up, vehicle.delivered, vehicle.canceled) have an entity.type of Vehicle:
{
"id": "a7k3m9x2n4p1q",
"event": "vehicle.picked_up",
"timestamp": "2026-02-08T12:00:00+00:00",
"entity": {
"type": "Vehicle",
"id": "9f8b0e6c4d1e4b3aaf2c5d6e7f8a9b0c"
},
"data": {
"id": "9f8b0e6c4d1e4b3aaf2c5d6e7f8a9b0c",
"vin": "1HGBH41JXMN109186",
"status": "picked_up",
"year": 2024,
"make": "Honda",
"model": "Accord",
"color": "Black",
"operable": true,
"oversized": false,
"rate": 850.00,
"tracking_url": "https://...",
"customer_id": "c1a2b3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"order_id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"pick_up_date_actual": "2026-02-08T10:30:00+00:00",
"delivery_date_actual": null,
"created_at": "2026-02-01T08:00:00+00:00",
"updated_at": "2026-02-08T10:30:00+00:00"
}
}
Order event payload
Order events (order.created, order.picked_up, order.delivered, order.canceled) have an entity.type of Order and a data block that includes nested origin / destination objects and a vehicles array:
{
"id": "b8l4n0y3o5q2r",
"event": "order.created",
"timestamp": "2026-02-08T12:00:00+00:00",
"entity": {
"type": "Order",
"id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9"
},
"data": {
"id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"customer_id": "c1a2b3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"reference_number": "REF-12345",
"origin": {
"name": "Origin Auto Auction",
"address": "123 Main St",
"city": "Newark",
"state": "NJ",
"postal_code": "07102"
},
"destination": {
"name": "Destination Dealership",
"address": "456 Market St",
"city": "San Francisco",
"state": "CA",
"postal_code": "94103"
},
"vehicles": [
{
"id": "9f8b0e6c4d1e4b3aaf2c5d6e7f8a9b0c",
"vin": "1HGBH41JXMN109186",
"status": "ordered",
"year": 2024,
"make": "Honda",
"model": "Accord",
"color": "Black",
"operable": true,
"oversized": false,
"rate": 850.00,
"tracking_url": "https://...",
"customer_id": "c1a2b3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"order_id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"pick_up_date_actual": null,
"delivery_date_actual": null,
"created_at": "2026-02-01T08:00:00+00:00",
"updated_at": "2026-02-01T08:00:00+00:00"
}
],
"created_at": "2026-02-01T08:00:00+00:00",
"updated_at": "2026-02-01T08:00:00+00:00"
}
}
Inspection event payload
Inspection events (vehicle.pickup_inspection_completed, vehicle.delivery_inspection_completed) are vehicle-level events — entity.type is Vehicle and the data block contains the full vehicle payload with an additional inspection object:
{
"id": "c9m5o1z4p6r3s",
"event": "vehicle.pickup_inspection_completed",
"timestamp": "2026-02-08T10:30:00+00:00",
"entity": {
"type": "Vehicle",
"id": "9f8b0e6c4d1e4b3aaf2c5d6e7f8a9b0c"
},
"data": {
"id": "9f8b0e6c4d1e4b3aaf2c5d6e7f8a9b0c",
"vin": "1HGBH41JXMN109186",
"status": "picked_up",
"year": 2024,
"make": "Honda",
"model": "Accord",
"color": "Black",
"operable": true,
"oversized": false,
"rate": 850.00,
"tracking_url": "https://...",
"customer_id": "c1a2b3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"order_id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"pick_up_date_actual": "2026-02-08T10:30:00+00:00",
"delivery_date_actual": null,
"created_at": "2026-02-01T08:00:00+00:00",
"updated_at": "2026-02-08T10:30:00+00:00",
"inspection": {
"id": 456,
"type": "pickup",
"status": "complete",
"vin_last_6": "109186",
"damages": [
{ "code": "scratched", "location": "front", "position": { "x": 100, "y": 50 } }
],
"photos": [
{ "id": 1, "type": "front", "url": "https://s3.../signed-url" },
{ "id": 2, "type": "rear", "url": "https://s3.../signed-url" }
],
"signatures": {
"customer": "https://s3.../signed-url",
"driver": "https://s3.../signed-url"
},
"created_at": "2026-02-08T10:00:00+00:00",
"updated_at": "2026-02-08T10:30:00+00:00"
}
}
}
Verifying Signatures
Every webhook request includes three security headers:
| Header | Description |
|---|---|
| X-Webhook-Signature | HMAC-SHA256 signature of timestamp.payload |
| X-Webhook-Timestamp | Unix timestamp (for replay attack prevention) |
| X-Webhook-Id | Unique delivery ID (for idempotency) |
To verify a signature:
- Compute
HMAC-SHA256(secret, timestamp + "." + raw_body) - Compare the result to the
X-Webhook-Signatureheader using a timing-safe comparison - Verify the timestamp is within 5 minutes of the current time
PHP
$secret = 'your-webhook-secret';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'];
$payload = file_get_contents('php://input');
// Verify timestamp is within 5 minutes
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit('Timestamp expired');
}
// Compute expected signature
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
// Timing-safe comparison
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
// Process the webhook
$data = json_decode($payload, true);
// ...
Python
import hmac, hashlib, time
secret = b'your-webhook-secret'
signature = request.headers['X-Webhook-Signature']
timestamp = request.headers['X-Webhook-Timestamp']
payload = request.get_data(as_text=True)
# Verify timestamp is within 5 minutes
if abs(time.time() - int(timestamp)) > 300:
abort(401, 'Timestamp expired')
# Compute expected signature
expected = hmac.new(secret, f'{timestamp}.{payload}'.encode(), hashlib.sha256).hexdigest()
# Timing-safe comparison
if not hmac.compare_digest(expected, signature):
abort(401, 'Invalid signature')
# Process the webhook
data = request.get_json()
# ...
Node.js
const crypto = require('crypto');
const secret = 'your-webhook-secret';
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const payload = req.rawBody; // Ensure raw body is available
// Verify timestamp is within 5 minutes
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return res.status(401).send('Timestamp expired');
}
// Compute expected signature
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
// Timing-safe comparison (guard against length mismatch)
const expectedBuf = Buffer.from(expected, 'hex');
const signatureBuf = Buffer.from(signature, 'hex');
if (expectedBuf.length !== signatureBuf.length || !crypto.timingSafeEqual(expectedBuf, signatureBuf)) {
return res.status(401).send('Invalid signature');
}
// Process the webhook
const data = JSON.parse(payload);
// ...
Retry Behavior
If your endpoint returns a non-2xx status code or the connection times out, the delivery is marked as failed and retried automatically.
| Setting | Value |
|---|---|
| Retry interval | 5 minutes |
| Maximum attempts | 10 |
| Connection timeout | 10 seconds |
| Request timeout | 30 seconds |
After 10 failed attempts, the delivery is marked as
exhausted
and will not be retried. You can monitor delivery status via the
GET /api/webhooks/{id}/deliveries endpoint.
Duplicate events for the same entity may be suppressed within a short deduplication window (default 5 minutes) to avoid rapid status oscillation generating multiple deliveries.
Delivery Statuses
| Status | Description |
|---|---|
| pending | Delivery has been created and is awaiting HTTP execution |
| delivered | Your endpoint returned a 2xx response |
| failed | Your endpoint returned a non-2xx response or the connection timed out. Will be retried. |
| exhausted | Maximum retry attempts reached. No further retries will be made. |
Error Handling
Your endpoint should return a 2xx status code to acknowledge receipt. Any other status code is treated as a failure.
Best practices
- Return
200 OKas quickly as possible. Process the webhook data asynchronously if needed. - Use the
X-Webhook-Idheader for idempotency. Store processed IDs to avoid handling duplicate deliveries. - Always verify the signature before processing webhook data.
- Handle the timestamp check to prevent replay attacks.
Common API errors
| Status | Code | Cause |
|---|---|---|
| 401 | Unauthenticated | Missing or invalid API token |
| 403 | Forbidden | Token lacks PUBLIC_API permission |
| 404 | Not Found | Webhook does not exist or you don't have access |
| 422 | Validation Error | Invalid request data (check the errors field) |
Testing Locally
During development, you'll need a way to receive webhook requests on your local machine. Here are three approaches depending on your needs.
Webhook Inspection Tools
The simplest approach is to use a hosted URL that captures and displays incoming requests. These tools require no installation and let you inspect headers, payloads, and signatures immediately.
| Tool | URL | Notes |
|---|---|---|
| webhook.site | webhook.site | Free. Generates a unique URL instantly. Shows headers, body, and query params. |
| Pipedream RequestBin | pipedream.com/requestbin | Free. Captures requests with a shareable URL. Also supports workflows to process incoming data. |
Quick start with webhook.site:
- Go to webhook.site and copy your unique URL
- Register a webhook using the URL:
POST /api/webhooks { "url": "https://webhook.site/your-unique-id", "events": ["vehicle.created", "vehicle.picked_up"] } - Send a test event:
POST /api/webhooks/{id}/test - Check webhook.site — you should see the request with:
X-Webhook-SignatureheaderX-Webhook-TimestampheaderX-Webhook-Idheader- JSON body with
id,event,timestamp,entity, anddata
Tunneling to Localhost with ngrok
If you need to test your actual webhook handler code running locally, use ngrok to expose your local server to the internet via a public URL.
Setup:
- Install ngrok from ngrok.com/download and authenticate with your free account
- Start a tunnel to your local webhook handler:
ngrok http 8080 - Copy the generated HTTPS URL (e.g.,
https://a1b2c3d4.ngrok-free.app) - Register a webhook using the ngrok URL:
POST /api/webhooks { "url": "https://a1b2c3d4.ngrok-free.app/webhooks/carpool", "events": ["vehicle.picked_up", "order.created"] } - Webhook requests will now be forwarded to
http://localhost:8080/webhooks/carpool
http://127.0.0.1:4040 where you can inspect all requests flowing through the tunnel, replay them, and debug your handler.